fix: persist SDK session_id on init + split long messages before adapter truncation

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.
This commit is contained in:
Dave Kim
2026-04-21 13:04:57 +00:00
parent c9977d6b69
commit 91c668e0cc
3 changed files with 84 additions and 7 deletions

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