revert: remove compaction destination reminder (PR #2327)

The compacted event handler injected a system-tagged reminder into the
live query after SDK auto-compaction, which caused the agent to send
an unintended message. Reverts the four changes from #2327:

- Remove `compacted` variant from ProviderEvent union
- Restore `result` yield for compact_boundary in ClaudeProvider
- Remove compacted event handler and getAllDestinations import in poll-loop
- Remove compaction integration tests and CompactingProvider helper

Closes #2325 differently — the reminder approach is not viable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-05-11 12:38:49 +03:00
parent 48dfb1b1e0
commit a760da7fef
4 changed files with 3 additions and 138 deletions

View File

@@ -295,115 +295,8 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {}); 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 <message to="…"> 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('<message to="name">');
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<void>((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: '<message to="discord-test">ack</message>' };
while (!ended && !aborted) {
await new Promise<void>((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 // Helper: run poll loop until aborted or timeout
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> { async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
return Promise.race([ return Promise.race([

View File

@@ -1,4 +1,4 @@
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; import { findByName, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js'; import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
@@ -378,23 +378,6 @@ async function processQuery(
if (event.text) { if (event.text) {
dispatchResultText(event.text, routing); dispatchResultText(event.text, routing);
} }
} else if (event.type === 'compacted') {
// The SDK auto-compacted the conversation. After compaction the
// model often drops the learned `<message to="…">` 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 nanocoai/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 <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
);
}
} }
} }
} finally { } finally {
@@ -421,9 +404,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
case 'progress': case 'progress':
log(`Progress: ${event.message}`); log(`Progress: ${event.message}`);
break; break;
case 'compacted':
log(`Compacted: ${event.text}`);
break;
} }
} }

View File

@@ -336,7 +336,7 @@ export class ClaudeProvider implements AgentProvider {
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata; const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : ''; const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
yield { type: 'compacted', text: `Context compacted${detail}.` }; yield { type: 'result', text: `Context compacted${detail}.` };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { summary?: string }; const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' }; yield { type: 'progress', message: tn.summary || 'Task notification' };

View File

@@ -89,12 +89,4 @@ export type ProviderEvent =
* event (tool call, thinking, partial message, anything) so the * event (tool call, thinking, partial message, anything) so the
* poll-loop's idle timer stays honest during long tool runs. * 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 `<message to="…">` wrapping
* after compaction. Distinct from `result` so it doesn't mark the turn
* completed or get dispatched as a chat message. See nanocoai/nanoclaw#2325.
*/
| { type: 'compacted'; text: string };