diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 3f0e364..119b1d4 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -322,6 +322,13 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise if (event.type === 'init') { queryContinuation = event.continuation; + // Persist immediately so a mid-turn container crash still lets the + // next wake resume the conversation. Without this, the session id + // was only written after the full stream completed — if the + // container died between `init` and `result`, the SDK session was + // effectively orphaned and the next message started a blank + // Claude session with no prior context. + setStoredSessionId(event.continuation); } else if (event.type === 'result' && event.text) { dispatchResultText(event.text, routing); } diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 7ddad4f..7e3c4ff 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -2,12 +2,40 @@ import { describe, expect, it } from 'vitest'; import type { Adapter } from 'chat'; -import { createChatSdkBridge } from './chat-sdk-bridge.js'; +import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js'; function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } +describe('splitForLimit', () => { + it('returns a single chunk when text fits', () => { + expect(splitForLimit('short text', 100)).toEqual(['short text']); + }); + + it('splits on paragraph boundaries when available', () => { + const text = 'para one line one\npara one line two\n\npara two line one\npara two line two'; + const chunks = splitForLimit(text, 40); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(40); + }); + + it('falls back to line boundaries when no paragraph fits', () => { + const text = 'alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot'; + const chunks = splitForLimit(text, 15); + expect(chunks.length).toBeGreaterThan(1); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(15); + }); + + it('hard-cuts when no whitespace is available', () => { + const text = 'a'.repeat(100); + const chunks = splitForLimit(text, 30); + expect(chunks.length).toBe(Math.ceil(100 / 30)); + for (const c of chunks) expect(c.length).toBeLessThanOrEqual(30); + expect(chunks.join('')).toBe(text); + }); +}); + describe('createChatSdkBridge', () => { // The bridge is now transport-only: forward inbound events, relay outbound // ops. All per-wiring engage / accumulate / drop / subscribe decisions live diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index ef2195e..5c120e0 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -63,6 +63,38 @@ export interface ChatSdkBridgeConfig { * quirk (e.g. Telegram's legacy Markdown parse mode). */ transformOutboundText?: (text: string) => string; + /** + * Maximum text length the underlying adapter accepts in a single message. + * When set, the bridge splits outbound text longer than this on paragraph + * → line → hard-char boundaries and posts multiple messages. Without this, + * adapters like Discord (2000) and Telegram (4096) silently truncate + * mid-response. The returned id is the first chunk's id so subsequent edits + * and reactions still target the head of the reply. + */ + maxTextLength?: number; +} + +/** + * Split `text` into chunks no larger than `limit`, preferring paragraph + * breaks, then line breaks, then a hard character cut as a last resort. + * Preserves code fences only structurally — a fenced block that straddles a + * chunk boundary will render as two independent blocks on the receiving + * platform, which is the same behavior as manually re-opening a fence. + */ +export function splitForLimit(text: string, limit: number): string[] { + if (text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + let cut = remaining.lastIndexOf('\n\n', limit); + if (cut <= 0) cut = remaining.lastIndexOf('\n', limit); + if (cut <= 0) cut = remaining.lastIndexOf(' ', limit); + if (cut <= 0) cut = limit; + chunks.push(remaining.slice(0, cut).trimEnd()); + remaining = remaining.slice(cut).trimStart(); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; } export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter { @@ -338,13 +370,23 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter data: f.data, filename: f.filename, })); - if (fileUploads && fileUploads.length > 0) { - const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads }); - return result?.id; - } else { - const result = await adapter.postMessage(tid, { markdown: text }); - return result?.id; + // Split if over the adapter's max length. Files ride on the first + // chunk so the head of the reply still carries them. + const chunks = + config.maxTextLength && text.length > config.maxTextLength + ? splitForLimit(text, config.maxTextLength) + : [text]; + let firstId: string | undefined; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const attachFiles = i === 0 && fileUploads && fileUploads.length > 0; + const result = await adapter.postMessage( + tid, + attachFiles ? { markdown: chunk, files: fileUploads } : { markdown: chunk }, + ); + if (i === 0) firstId = result?.id; } + return firstId; } else if (message.files && message.files.length > 0) { // Files only, no text const fileUploads = message.files.map((f: { data: Buffer; filename: string }) => ({