diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index f309cc3..9d243b2 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -129,8 +129,116 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + + it('should inject destination reminder after a compacted event', async () => { + // Two destinations — required for the reminder to fire (single-destination + // groups have a fallback path that works without wrapping). + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`, + ) + .run(); + + insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new CompactingProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500); + + await waitFor(() => getUndeliveredMessages().length > 0, 2500); + controller.abort(); + + expect(provider.pushes.length).toBeGreaterThanOrEqual(1); + const reminder = provider.pushes.find((p) => p.includes('Context was just compacted')); + expect(reminder).toBeDefined(); + expect(reminder).toContain('2 destinations'); + expect(reminder).toContain('discord-test'); + expect(reminder).toContain('discord-second'); + expect(reminder).toContain(''); + + await loopPromise.catch(() => {}); + }); + + it('should NOT inject destination reminder with a single destination', async () => { + insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new CompactingProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500); + + await waitFor(() => getUndeliveredMessages().length > 0, 2500); + controller.abort(); + + // Only the original prompt push (if any) — no reminder, since beforeEach + // seeds exactly one destination. + const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted')); + expect(reminders).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); }); +/** + * Provider that emits a single compacted event mid-stream, then returns a + * result. Captures every push() call so tests can assert on the injected + * reminder content. + */ +class CompactingProvider { + readonly supportsNativeSlashCommands = false; + readonly pushes: string[] = []; + + isSessionInvalid(): boolean { + return false; + } + + query(_input: { prompt: string; cwd: string }) { + const pushes = this.pushes; + let ended = false; + let aborted = false; + let resolveWaiter: (() => void) | null = null; + + async function* events() { + yield { type: 'activity' as const }; + yield { type: 'init' as const, continuation: 'compaction-test-session' }; + yield { type: 'activity' as const }; + yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' }; + + // Wait for poll-loop to push the reminder (or end / abort) + await new Promise((resolve) => { + resolveWaiter = resolve; + // Belt-and-braces: don't hang forever if the reminder never arrives + setTimeout(resolve, 200); + }); + + yield { type: 'activity' as const }; + yield { type: 'result' as const, text: 'ack' }; + while (!ended && !aborted) { + await new Promise((resolve) => { + resolveWaiter = resolve; + setTimeout(resolve, 50); + }); + } + } + + return { + push(message: string) { + pushes.push(message); + resolveWaiter?.(); + }, + end() { + ended = true; + resolveWaiter?.(); + }, + abort() { + aborted = true; + resolveWaiter?.(); + }, + events: events(), + }; + } +} + // Helper: run poll loop until aborted or timeout async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { return Promise.race([ diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 35abb83..804d1f2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -366,6 +366,23 @@ async function processQuery( if (event.text) { dispatchResultText(event.text, routing); } + } else if (event.type === 'compacted') { + // The SDK auto-compacted the conversation. After compaction the + // model often drops the learned `` wrapping + // discipline (the destinations are still in the system prompt, + // but the behavioral pattern is summarized away). Inject a + // reminder back into the live query so the next turn re-anchors + // on the destination model. Only do this when there's >1 + // destination — single-destination groups have a fallback that + // works without wrapping. See qwibitai/nanoclaw#2325. + const destinations = getAllDestinations(); + if (destinations.length > 1) { + const names = destinations.map((d) => d.name).join(', '); + query.push( + `[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` + + `Use blocks to address them. Bare text goes to the scratchpad fallback only.`, + ); + } } } } finally { @@ -390,6 +407,9 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { case 'progress': log(`Progress: ${event.message}`); break; + case 'compacted': + log(`Compacted: ${event.text}`); + break; } } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6c30cc2..6850e51 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -329,7 +329,7 @@ export class ClaudeProvider implements AgentProvider { } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata; const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : ''; - yield { type: 'result', text: `Context compacted${detail}.` }; + yield { type: 'compacted', text: `Context compacted${detail}.` }; } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { const tn = message as { summary?: string }; yield { type: 'progress', message: tn.summary || 'Task notification' }; diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..b4b1fc8 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -79,4 +79,12 @@ export type ProviderEvent = * event (tool call, thinking, partial message, anything) so the * poll-loop's idle timer stays honest during long tool runs. */ - | { type: 'activity' }; + | { type: 'activity' } + /** + * The provider's underlying SDK auto-compacted the conversation context. + * The poll-loop reacts by injecting a destination reminder back into + * the live query so the agent doesn't drop `` wrapping + * after compaction. Distinct from `result` so it doesn't mark the turn + * completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325. + */ + | { type: 'compacted'; text: string };