From 91c668e0cc2795fd63351059af7594c363014568 Mon Sep 17 00:00:00 2001 From: Dave Kim Date: Tue, 21 Apr 2026 13:04:57 +0000 Subject: [PATCH] fix: persist SDK session_id on init + split long messages before adapter truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs that surfaced together when a Discord response exceeded 2000 chars: 1. **Session id lost on mid-turn container exit.** `runPollLoop` was calling `setStoredSessionId` only after `processQuery` returned. If the container died between the SDK's `init` event (where session_id arrives) and the stream completing, the id was never persisted. The next wake called `getStoredSessionId()` → undefined and started a fresh Claude session, dropping all prior context. Fix: persist immediately in the `init` branch inside `processQuery`. The existing post-query store becomes a harmless no-op. 2. **Silent truncation past adapter limits.** `chat-sdk-bridge.deliver` handed full text straight to `adapter.postMessage`. Discord's adapter hard-truncates at 2000 chars; Telegram's at 4096. Responses longer than that were cut off without any signal to the user or host. Fix: add `maxTextLength` to `ChatSdkBridgeConfig` and a `splitForLimit` helper that breaks on paragraph → line → hard-char boundaries, then posts chunks sequentially. Files ride on the first chunk; the returned id is the first chunk's so edits and reactions still target the reply head. Channel adapter files (Discord, Telegram, …) live on the `channels` branch — a companion PR wires `maxTextLength: 1900` for Discord and `4000` for Telegram so the splitter actually engages in those installs. Without wiring, behavior is unchanged. --- container/agent-runner/src/poll-loop.ts | 7 ++++ src/channels/chat-sdk-bridge.test.ts | 30 +++++++++++++- src/channels/chat-sdk-bridge.ts | 54 ++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 3f0e364be..119b1d499 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 7ddad4ff0..7e3c4ffd6 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 ef2195e7a..5c120e074 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 }) => ({