diff --git a/src/__tests__/messageFormatter.test.ts b/src/__tests__/messageFormatter.test.ts index 2a7033f..dc9a2df 100644 --- a/src/__tests__/messageFormatter.test.ts +++ b/src/__tests__/messageFormatter.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { parseSSEEvent, extractTextFromPart, accumulateText, formatOutput, stripAnsi, buildContextHeader } from '../utils/messageFormatter.js'; +import { + parseSSEEvent, + extractTextFromPart, + accumulateText, + formatOutput, + stripAnsi, + buildContextHeader, + splitIntoChunks, + splitForDiscordTemplate, + DISCORD_MAX_LENGTH, +} from '../utils/messageFormatter.js'; describe('messageFormatter', () => { describe('stripAnsi', () => { @@ -107,5 +117,97 @@ describe('messageFormatter', () => { const buffer = 'Line1\nLine2\nLine3'; expect(formatOutput(buffer)).toBe('Line1\nLine2\nLine3'); }); + + it('should respect custom maxLength (body slice only)', () => { + const long = 'a'.repeat(500); + const truncated = formatOutput(long, 100); + // formatOutput applies maxLength to the body slice; the truncation + // notice adds ~19 chars of overhead on top. + expect(truncated.length).toBeLessThanOrEqual(100 + 19); + expect(truncated.endsWith('a'.repeat(100))).toBe(true); + expect(truncated.startsWith('...(truncated)...')).toBe(true); + }); + }); + + describe('splitIntoChunks', () => { + it('returns a single chunk when text fits', () => { + expect(splitIntoChunks('hello', 100)).toEqual(['hello']); + }); + + it('splits long text on double-newline boundaries when possible', () => { + const part = 'a'.repeat(80); + const text = `${part}\n\n${part}\n\n${part}\n\n${part}`; + const chunks = splitIntoChunks(text, 100); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100); + }); + + it('falls back to single newline when no double newline in window', () => { + const lines = Array.from({ length: 50 }, (_, i) => `line${i}`).join('\n'); + const chunks = splitIntoChunks(lines, 60); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(60); + }); + + it('hard splits when no newline fits in the window', () => { + const text = 'x'.repeat(500); + const chunks = splitIntoChunks(text, 100); + expect(chunks.length).toBe(5); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100); + }); + + it('strips leading newlines between chunks', () => { + const text = Array.from({ length: 20 }, () => 'p').join('\n\n'); + const chunks = splitIntoChunks(text, 30); + for (const c of chunks) expect(c.startsWith('\n')).toBe(false); + }); + }); + + describe('splitForDiscordTemplate', () => { + it('keeps everything in prefixBody when body fits', () => { + const r = splitForDiscordTemplate({ + header: '๐ŸŒฟ `main` ยท ๐Ÿค– `default`', + prompt: 'hi', + body: 'short body', + }); + expect(r.overflowChunks).toEqual([]); + expect(r.prefixBody).toContain('๐Ÿ“Œ **Prompt**: hi'); + expect(r.prefixBody).toContain('short body'); + expect(r.prefixBody.length).toBeLessThanOrEqual(DISCORD_MAX_LENGTH); + }); + + it('truncates and overflows when body is too large', () => { + const body = 'a'.repeat(5000); + const r = splitForDiscordTemplate({ + header: '๐ŸŒฟ `main` ยท ๐Ÿค– `default`', + prompt: 'p', + body, + }); + expect(r.prefixBody.length).toBeLessThanOrEqual(DISCORD_MAX_LENGTH); + expect(r.prefixBody.endsWith('...')).toBe(true); + expect(r.overflowChunks.length).toBeGreaterThan(0); + for (const c of r.overflowChunks) expect(c.length).toBeLessThanOrEqual(DISCORD_MAX_LENGTH); + }); + + it('respects a custom maxLength', () => { + const r = splitForDiscordTemplate({ + header: 'h', + prompt: 'p', + body: 'a'.repeat(1000), + maxLength: 500, + }); + expect(r.prefixBody.length).toBeLessThanOrEqual(500); + expect(r.overflowChunks.length).toBeGreaterThan(0); + }); + + it('handles a body smaller than the minimum budget without overflowing', () => { + const r = splitForDiscordTemplate({ + header: 'h', + prompt: 'p', + body: '', + }); + expect(r.overflowChunks).toEqual([]); + expect(r.prefixBody).toContain('๐Ÿ“Œ **Prompt**: p'); + }); }); }); diff --git a/src/__tests__/serveManager.test.ts b/src/__tests__/serveManager.test.ts index e29e17e..f41f0b9 100644 --- a/src/__tests__/serveManager.test.ts +++ b/src/__tests__/serveManager.test.ts @@ -320,9 +320,14 @@ describe("serveManager", () => { await vi.runAllTimersAsync(); await expect(promise).resolves.toBeUndefined(); - expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:14097/session", { - headers: {}, - }); + expect(fetch).toHaveBeenCalledWith( + "http://127.0.0.1:14097/session", + expect.objectContaining({ headers: {} }), + ); + expect(fetch).toHaveBeenCalledWith( + "http://127.0.0.1:14097/session", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); }); it("should retry if fetch fails or returns not ok", async () => { @@ -341,13 +346,53 @@ describe("serveManager", () => { expect(fetch).toHaveBeenCalledTimes(3); }); + it("should retry on AbortSignal timeout without aborting the wait loop", async () => { + vi.mocked(fetch) + .mockRejectedValueOnce( + new DOMException("The operation was aborted due to timeout", "TimeoutError"), + ) + .mockRejectedValueOnce( + new DOMException("The operation was aborted due to timeout", "TimeoutError"), + ) + .mockResolvedValueOnce({ ok: true } as Response); + + const promise = serveManager.waitForReady(14097); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + await expect(promise).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("should tolerate a slow cold-start longer than the legacy 30s default", async () => { + // Server takes ~45s of simulated polling before becoming ready. + // With the legacy 30s default this would fail; with the new 60s default + // it should succeed. + let calls = 0; + vi.mocked(fetch).mockImplementation(async () => { + calls++; + if (calls < 45) return { ok: false } as Response; + return { ok: true } as Response; + }); + + const promise = serveManager.waitForReady(14097); + for (let i = 0; i < 46; i++) { + await vi.advanceTimersByTimeAsync(1000); + } + + await expect(promise).resolves.toBeUndefined(); + expect(calls).toBe(45); + }); + it("should throw error on timeout", async () => { vi.mocked(fetch).mockRejectedValue(new Error("Connection refused")); const promise = serveManager.waitForReady(14097, 1000); const wrappedPromise = expect(promise).rejects.toThrow( - "Service at port 14097 failed to become ready within 1000ms. Check if 'opencode serve' is working correctly.", + "Service at port 14097 failed to become ready within 1000ms", ); await vi.advanceTimersByTimeAsync(1500); @@ -418,9 +463,10 @@ describe("serveManager", () => { await expect(promise).resolves.toBeUndefined(); const expected = `Basic ${Buffer.from("opencode:s3cret").toString("base64")}`; - expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:14097/session", { - headers: { Authorization: expected }, - }); + expect(fetch).toHaveBeenCalledWith( + "http://127.0.0.1:14097/session", + expect.objectContaining({ headers: { Authorization: expected } }), + ); }); it("fails fast with a clear error when readiness probe returns 401", async () => { diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 25d9c41..61c6cd0 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -1,6 +1,6 @@ -import { - ActionRowBuilder, - ButtonBuilder, +import { + ActionRowBuilder, + ButtonBuilder, ButtonStyle, Message, TextBasedChannel, @@ -11,7 +11,13 @@ import * as sessionManager from './sessionManager.js'; import * as serveManager from './serveManager.js'; import * as worktreeManager from './worktreeManager.js'; import { SSEClient } from './sseClient.js'; -import { formatOutput, formatOutputForMobile, buildContextHeader } from '../utils/messageFormatter.js'; +import { + formatOutput, + formatOutputForMobile, + buildContextHeader, + splitForDiscordTemplate, + DISCORD_MAX_LENGTH, +} from '../utils/messageFormatter.js'; import { processNextInQueue } from './queueManager.js'; export async function runPrompt( @@ -93,12 +99,29 @@ export async function runPrompt( ); let streamMessage: Message; + // Defensively truncate the prompt in the initial message so very long + // user prompts don't push the starting message past Discord's 2000-char + // limit and cause `channel.send` to throw before we get anywhere. + const safePrompt = prompt.length > 1500 + ? `${prompt.slice(0, 1500)}โ€ฆ (truncated)` + : prompt; + const initialContent = `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${safePrompt}\n\n๐Ÿš€ Starting OpenCode server...`; try { streamMessage = await (channel as any).send({ - content: `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\n๐Ÿš€ Starting OpenCode server...`, + content: initialContent.length <= DISCORD_MAX_LENGTH + ? initialContent + : `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${safePrompt}\n\n๐Ÿš€ Starting OpenCode server...`, components: [buttons] }); - } catch { + } catch (error) { + console.error('Failed to send initial stream message:', error instanceof Error ? error.message : error); + try { + await (channel as any).send( + `โŒ Could not start: prompt is too long for Discord (max ~${DISCORD_MAX_LENGTH} chars).`, + ); + } catch { + // Nothing more we can do; the channel is unreachable. + } return; } @@ -110,15 +133,73 @@ export async function runPrompt( let tick = 0; let promptSent = false; let hasSessionError = false; + // Mitigation for Discord REST timeouts under sustained streaming: + // discord.js's SequentialHandler queues edits on the same bucket, and + // undici's default connectTimeout is 10s. If a single edit stalls + // (Cloudflare keep-alive drop, transient network blip, etc.) every + // subsequent 1Hz tick piles another request into the queue and they all + // timeout in cascade, leaving the stream message frozen. + // 1. We skip interval ticks while an edit is still in flight, so the + // SequentialHandler queue cannot grow unbounded. + // 2. We race each edit against an explicit 8s timeout so a stuck edit + // fails fast and the next tick can try again with a fresh connection + // instead of waiting for undici's 10s default. + const STREAM_EDIT_TIMEOUT_MS = 8000; + let streamEditInFlight = false; const spinner = ['โ ‹', 'โ ™', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ‡', 'โ ']; - - const updateStreamMessage = async (content: string, components: ActionRowBuilder[]): Promise => { + + const updateStreamMessage = async (body: string, components: ActionRowBuilder[]): Promise => { + if (streamEditInFlight) { + // Another edit is still pending in discord.js's queue โ€” drop this tick. + // The in-flight edit will complete (or fail fast via the timeout) and + // the next tick will retry with fresh content. + return false; + } + streamEditInFlight = true; + let timeoutHandle: NodeJS.Timeout | null = null; try { - await streamMessage.edit({ content, components }); + const { prefixBody, overflowChunks } = splitForDiscordTemplate({ + header: contextHeader, + prompt, + body, + }); + // Race the edit against a hard timeout so a stuck REST request can't + // hold streamEditInFlight forever. discord.js's edit() does not + // natively accept AbortSignal, so we race against a timeout that + // rejects if the edit hangs longer than STREAM_EDIT_TIMEOUT_MS. + let timedOut = false; + const editPromise = streamMessage.edit({ content: prefixBody, components }); + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + reject(new Error(`stream edit timed out after ${STREAM_EDIT_TIMEOUT_MS}ms`)); + }, STREAM_EDIT_TIMEOUT_MS); + }); + try { + await Promise.race([editPromise, timeoutPromise]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } + if (timedOut) { + // The underlying REST request is still pending in discord.js's queue + // and will eventually resolve/reject on its own; we just stop waiting + // so the next interval tick can proceed. + console.error(`stream edit exceeded ${STREAM_EDIT_TIMEOUT_MS}ms; abandoning wait and continuing`); + return false; + } + for (const chunk of overflowChunks) { + try { + await (channel as any).send({ content: chunk }); + } catch (sendErr) { + console.error('Failed to send overflow chunk:', sendErr instanceof Error ? sendErr.message : sendErr); + } + } return true; } catch (error) { console.error('Failed to edit stream message:', error instanceof Error ? error.message : error); return false; + } finally { + streamEditInFlight = false; } }; @@ -135,7 +216,7 @@ export async function runPrompt( try { port = await serveManager.spawnServe(effectivePath, preferredModel); - await updateStreamMessage(`${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\nโณ Waiting for OpenCode server...`, [buttons]); + await updateStreamMessage('โณ Waiting for OpenCode server...', [buttons]); await serveManager.waitForReady(port, 30000, effectivePath, preferredModel); const settings = dataStore.getQueueSettings(threadId); @@ -184,8 +265,8 @@ export async function runPrompt( if (!accumulatedText.trim()) { const edited = await updateStreamMessage( - `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\nโš ๏ธ No output received โ€” the model may have encountered an issue.`, - [disabledButtons] + 'โš ๏ธ No output received โ€” the model may have encountered an issue.', + [disabledButtons], ); if (!edited) { await safeSend('โš ๏ธ No output received โ€” the model may have encountered an issue.'); @@ -193,18 +274,30 @@ export async function runPrompt( await safeSend('โš ๏ธ Done (no output received)'); } else { const result = formatOutputForMobile(accumulatedText); - - const editSuccess = await updateStreamMessage( - `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\n${result.chunks[0]}`, - [disabledButtons] - ); - - // If edit failed (e.g., content exceeds Discord's 2000-char limit), send all chunks as new messages - const startIndex = editSuccess ? 1 : 0; - for (let i = startIndex; i < result.chunks.length; i++) { - await safeSend(result.chunks[i]); + const fullBody = result.chunks.join('\n\n'); + const { prefixBody, overflowChunks } = splitForDiscordTemplate({ + header: contextHeader, + prompt, + body: fullBody, + }); + + const editSuccess = await streamMessage + .edit({ content: prefixBody, components: [disabledButtons] }) + .then(() => true) + .catch((err) => { + console.error( + 'Failed to edit stream message:', + err instanceof Error ? err.message : err, + ); + return false; + }); + + const remaining = overflowChunks.length > 0 + ? overflowChunks + : (editSuccess ? result.chunks.slice(1) : result.chunks); + for (const chunk of remaining) { + await safeSend(chunk); } - await safeSend('โœ… Done'); } @@ -243,8 +336,8 @@ export async function runPrompt( ); const edited = await updateStreamMessage( - `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\nโŒ **Error**: ${errorMsg}`, - [disabledButtons] + `โŒ **Error**: ${errorMsg}`, + [disabledButtons], ); if (!edited) { await safeSend(`โŒ **Error**: ${errorMsg}`); @@ -275,7 +368,7 @@ export async function runPrompt( (async () => { try { - const edited = await updateStreamMessage(`${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\nโŒ Connection error: ${error.message}`, []); + const edited = await updateStreamMessage(`โŒ Connection error: ${error.message}`, []); if (!edited) { await safeSend(`โŒ Connection error: ${error.message}`); } @@ -303,20 +396,20 @@ export async function runPrompt( const formatted = formatOutput(accumulatedText); const spinnerChar = spinner[tick % spinner.length]; const newContent = formatted || 'Processing...'; - + if (newContent !== lastContent || tick % 2 === 0) { lastContent = newContent; await updateStreamMessage( - `${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n${newContent}`, - [buttons] + `${spinnerChar} **Running...**\n${newContent}`, + [buttons], ); } } catch (error) { console.error('Error in stream update interval:', error instanceof Error ? error.message : error); } }, 1000); - - await updateStreamMessage(`${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\n๐Ÿ“ Sending prompt...`, [buttons]); + + await updateStreamMessage('๐Ÿ“ Sending prompt...', [buttons]); await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel); promptSent = true; @@ -326,7 +419,7 @@ export async function runPrompt( } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const edited = await updateStreamMessage(`${contextHeader}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\nโŒ OpenCode execution failed: ${errorMessage}`, []); + const edited = await updateStreamMessage(`โŒ OpenCode execution failed: ${errorMessage}`, []); if (!edited) { await safeSend(`โŒ OpenCode execution failed: ${errorMessage}`); } diff --git a/src/services/serveManager.ts b/src/services/serveManager.ts index 8b98d21..ad71066 100644 --- a/src/services/serveManager.ts +++ b/src/services/serveManager.ts @@ -8,6 +8,8 @@ import { getAuthHeaders, isAuthEnabled } from "./serverAuth.js"; const DEFAULT_PORT_MIN = 14097; const DEFAULT_PORT_MAX = 14200; +const READINESS_PROBE_TIMEOUT_MS = 5000; +const DEFAULT_READINESS_TIMEOUT_MS = 60000; const WINDOWS_OPENCODE_COMMANDS = ["opencode.cmd", "opencode.exe", "opencode"]; const POSIX_OPENCODE_COMMANDS = ["opencode"]; @@ -268,7 +270,7 @@ export function stopServe(projectPath: string, model?: string): boolean { export async function waitForReady( port: number, - timeout: number = 30000, + timeout: number = DEFAULT_READINESS_TIMEOUT_MS, projectPath?: string, model?: string, ): Promise { @@ -294,7 +296,13 @@ export async function waitForReady( } try { - const response = await fetch(url, { headers: getAuthHeaders() }); + const response = await fetch(url, { + headers: getAuthHeaders(), + // Per-probe timeout so a single slow/hanging request can't eat the + // entire wait budget. We retry every second until either the server + // responds OK or the outer timeout fires. + signal: AbortSignal.timeout(READINESS_PROBE_TIMEOUT_MS), + }); if (response.ok) { return; } @@ -314,6 +322,7 @@ export async function waitForReady( ) { throw err; } + // AbortSignal.timeout, ECONNREFUSED, etc. โ€” fall through to retry. } await new Promise((resolve) => setTimeout(resolve, 1000)); } @@ -331,7 +340,7 @@ export async function waitForReady( } throw new Error( - `Service at port ${port} failed to become ready within ${timeout}ms. Check if 'opencode serve' is working correctly.`, + `Service at port ${port} failed to become ready within ${timeout}ms. The opencode server may still be loading models โ€” try again in a few seconds. Check 'opencode serve' logs if the problem persists.`, ); } diff --git a/src/utils/messageFormatter.ts b/src/utils/messageFormatter.ts index fcc6f5b..f177372 100644 --- a/src/utils/messageFormatter.ts +++ b/src/utils/messageFormatter.ts @@ -116,13 +116,14 @@ export interface FormattedResult { chunks: string[]; } -const MESSAGE_MAX_LENGTH = 1900; +export const MESSAGE_MAX_LENGTH = 1900; +export const DISCORD_MAX_LENGTH = 2000; /** * Split text into chunks that fit within Discord's message limit. * Splits on paragraph boundaries (double newline) when possible. */ -function splitIntoChunks(text: string, maxLength: number): string[] { +export function splitIntoChunks(text: string, maxLength: number): string[] { if (text.length <= maxLength) { return [text]; } @@ -156,7 +157,7 @@ function splitIntoChunks(text: string, maxLength: number): string[] { export function formatOutputForMobile(buffer: string): FormattedResult { const parsed = parseOpenCodeOutput(buffer); - + if (!parsed.trim()) { return { chunks: ['โณ Processing...'] }; } @@ -164,3 +165,44 @@ export function formatOutputForMobile(buffer: string): FormattedResult { const chunks = splitIntoChunks(parsed, MESSAGE_MAX_LENGTH); return { chunks }; } + +export interface DiscordTemplateChunks { + /** Body to send via `message.edit()`. Always fits within `maxLength`. */ + prefixBody: string; + /** + * Remaining chunks (overflow) that must be sent as separate follow-up messages + * via `channel.send()`. Each chunk is already under `maxLength`. + * Empty when the body fit entirely inside the prefix. + */ + overflowChunks: string[]; +} + +/** + * Build a Discord-safe edit body from the streaming template + * (header + prompt + body) and return any overflow as separate chunks. + * + * Keeps the edited message under Discord's 2000-char limit while still showing + * the full prompt and as much of the body as fits, with the rest delivered as + * follow-up messages so the user never loses information. + */ +export function splitForDiscordTemplate( + { header, prompt, body, maxLength = DISCORD_MAX_LENGTH }: + { header: string; prompt: string; body: string; maxLength?: number }, +): DiscordTemplateChunks { + const prefixTemplate = `${header}\n๐Ÿ“Œ **Prompt**: ${prompt}\n\n`; + const overhead = prefixTemplate.length; + // Reserve a few chars for the "\n..." ellipsis when truncating. + const footerReserve = 4; + const bodyBudget = Math.max(100, maxLength - overhead - footerReserve); + + if (body.length <= bodyBudget) { + return { prefixBody: `${prefixTemplate}${body}`, overflowChunks: [] }; + } + + const truncated = body.slice(0, bodyBudget); + const rest = body.slice(bodyBudget); + return { + prefixBody: `${prefixTemplate}${truncated}\n...`, + overflowChunks: splitIntoChunks(rest, maxLength), + }; +}