Merge pull request #1900 from davekim917/fix/discord-truncation-and-session-persist

fix: persist SDK session_id on init + split long outbound messages
This commit is contained in:
gavrielc
2026-04-21 21:36:32 +03:00
committed by GitHub
3 changed files with 84 additions and 7 deletions
+7
View File
@@ -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);
}
+29 -1
View File
@@ -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>): 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
+48 -6
View File
@@ -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 }) => ({