Skip to content

Commit 61cbda2

Browse files
testikuntestikun
andauthored
feat(desktop): support manual context compaction in Side Conversations (#4532)
* feat(desktop): support side conversation context compaction Add exact /compact handling for idle committed Side Conversations, routing compaction to the companion Session and preserving existing feedback and draft ownership. Generated-by: Codex * fix(desktop): keep compaction helpers feature-private Generated-by: Codex * fix(desktop): release interrupted side compaction Generated-by: Codex * fix(desktop): fence pending side compaction terminal events Generated-by: Codex * test(desktop): keep side compaction cases top-level Generated-by: Codex * test(desktop): adapt compaction coverage to lazy forks Generated-by: Codex --------- Co-authored-by: testikun <testikun@google.com>
1 parent 57d5796 commit 61cbda2

12 files changed

Lines changed: 797 additions & 25 deletions

File tree

apps/desktop/src/main/__tests__/quote-companion-retry.test.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ import type {
3131
SessionSummary,
3232
TurnRecord,
3333
} from '@maka/core/session';
34+
import type { ContextCompactResult } from '@maka/runtime-host/protocol';
3435
import {
3536
createFakeWorkbarServices,
37+
dispatchQuoteCompanionInput,
3638
useQuoteCompanion,
3739
sessionHasExactModelChoice,
3840
WorkbarServicesProvider,
@@ -136,6 +138,7 @@ async function renderProbe(
136138
onStop?: (stop: () => Promise<void>) => void;
137139
onSetPermissionMode?: (set: (mode: PermissionMode) => Promise<boolean>) => void;
138140
confirmBypass?: () => Promise<boolean>;
141+
onContextCompactionError?: (sessionId: string, error: unknown) => void;
139142
pendingQuotes?: readonly StagedCompanionQuote[];
140143
onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void;
141144
} = {},
@@ -159,6 +162,7 @@ async function renderProbe(
159162
onSteer: options.onSteer,
160163
onStop: options.onStop,
161164
onSetPermissionMode: options.onSetPermissionMode,
165+
onContextCompactionError: options.onContextCompactionError,
162166
pendingQuotes: options.pendingQuotes,
163167
onQuotesConsumed: options.onQuotesConsumed,
164168
sourceSession: options.sourceSession,
@@ -191,6 +195,7 @@ async function renderOwnershipProbe(
191195
onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void;
192196
sourceSession?: SessionSummary;
193197
modelChoices?: readonly ChatModelChoice[];
198+
onContextCompactionError?: (sessionId: string, error: unknown) => void;
194199
} = {},
195200
) {
196201
let send!: (text: string) => Promise<boolean>;
@@ -233,6 +238,16 @@ async function renderOwnershipProbe(
233238
};
234239
}
235240

241+
async function commitIdleCompanion(
242+
rendered: Awaited<ReturnType<typeof renderOwnershipProbe>>,
243+
): Promise<void> {
244+
await act(async () => {
245+
assert.equal(await rendered.send('prepare side conversation'), false);
246+
await Promise.resolve();
247+
});
248+
await awaitCompanion(rendered.container);
249+
}
250+
236251
const REBOUND_MODEL: Partial<SessionSummary> = {
237252
llmConnectionId: 'connection-2',
238253
llmConnectionSlug: 'openai-2',
@@ -361,6 +376,319 @@ test('first send after a completed turn forks through the settled turn', async (
361376
assert.equal(probe.getAttribute('data-error'), '');
362377
});
363378

379+
test('dispatches /compact to the committed companion fork without sending model input', async () => {
380+
const compactCalls: string[] = [];
381+
let sendCalls = 0;
382+
let steerCalls = 0;
383+
const rendered = await renderOwnershipProbe({
384+
compact: async (sessionId) => {
385+
compactCalls.push(sessionId);
386+
return {
387+
kind: 'finished' as const,
388+
turn: {
389+
sessionId,
390+
turnId: 'compact-turn',
391+
runId: 'compact-run',
392+
status: 'completed' as const,
393+
terminalEventId: 'compact-complete',
394+
contextCompactionOutcome: { kind: 'unchanged' as const, reason: 'already_current' },
395+
},
396+
outcome: { kind: 'unchanged' as const, reason: 'already_current' },
397+
};
398+
},
399+
send: async () => {
400+
sendCalls += 1;
401+
return { ok: false as const, reason: 'seed only' };
402+
},
403+
steer: async () => {
404+
steerCalls += 1;
405+
return { kind: 'started' as const, turnId: 'unexpected-steer' };
406+
},
407+
});
408+
409+
await commitIdleCompanion(rendered);
410+
sendCalls = 0;
411+
assert.equal(await rendered.send(' /compact '), true);
412+
assert.deepEqual(compactCalls, ['side-conversation']);
413+
assert.equal(sendCalls, 0);
414+
assert.equal(steerCalls, 0);
415+
});
416+
417+
test('dispatches the exact /compact Composer command before steering or ordinary send', async () => {
418+
const calls: string[] = [];
419+
assert.equal(
420+
await dispatchQuoteCompanionInput({
421+
text: ' /compact ',
422+
streaming: true,
423+
compact: async () => {
424+
calls.push('compact');
425+
return true;
426+
},
427+
steer: async () => {
428+
calls.push('steer');
429+
return true;
430+
},
431+
send: async () => {
432+
calls.push('send');
433+
return true;
434+
},
435+
}),
436+
true,
437+
);
438+
assert.deepEqual(calls, ['compact']);
439+
});
440+
441+
test('keeps an async companion compaction exclusive until its terminal event', async () => {
442+
let compactCalls = 0;
443+
let sendCalls = 0;
444+
const rendered = await renderOwnershipProbe({
445+
compact: async (sessionId) => {
446+
compactCalls += 1;
447+
return {
448+
kind: 'started' as const,
449+
turn: {
450+
sessionId,
451+
turnId: 'compact-turn',
452+
runId: 'compact-run',
453+
status: 'running' as const,
454+
},
455+
};
456+
},
457+
send: async () => {
458+
sendCalls += 1;
459+
return { ok: false as const, reason: 'seed only' };
460+
},
461+
});
462+
463+
await commitIdleCompanion(rendered);
464+
sendCalls = 0;
465+
assert.equal(await rendered.send('/compact'), true);
466+
assert.equal(await rendered.send('ordinary question'), false);
467+
assert.equal(compactCalls, 1);
468+
assert.equal(sendCalls, 0);
469+
});
470+
471+
test('releases an async companion compaction after a Host interruption', async () => {
472+
let compactCalls = 0;
473+
const compactionErrors: Array<{ sessionId: string; error: unknown }> = [];
474+
const rendered = await renderOwnershipProbe(
475+
{
476+
compact: async (sessionId) => {
477+
compactCalls += 1;
478+
return {
479+
kind: 'started' as const,
480+
turn: {
481+
sessionId,
482+
turnId: `compact-turn-${compactCalls}`,
483+
runId: `compact-run-${compactCalls}`,
484+
status: 'running' as const,
485+
},
486+
};
487+
},
488+
},
489+
{
490+
onContextCompactionError: (sessionId, error) => {
491+
compactionErrors.push({ sessionId, error });
492+
},
493+
},
494+
);
495+
496+
await commitIdleCompanion(rendered);
497+
assert.equal(await rendered.send('/compact'), true);
498+
assert.equal(await rendered.send('/compact'), false);
499+
await act(async () => {
500+
rendered.emit({
501+
type: 'abort',
502+
id: 'compact-aborted',
503+
turnId: 'compact-turn-1',
504+
ts: 1,
505+
reason: 'crash',
506+
});
507+
await Promise.resolve();
508+
});
509+
510+
assert.equal(await rendered.send('/compact'), true);
511+
assert.equal(compactCalls, 2);
512+
assert.equal(compactionErrors.length, 1);
513+
assert.equal(compactionErrors[0]?.sessionId, 'side-conversation');
514+
assert.equal((compactionErrors[0]?.error as SessionEvent | undefined)?.type, 'abort');
515+
});
516+
517+
test('does not settle a pending companion compaction from another turn outcome', async () => {
518+
const pendingCompact = deferred<ContextCompactResult>();
519+
let compactCalls = 0;
520+
const rendered = await renderOwnershipProbe({
521+
compact: async (sessionId) => {
522+
compactCalls += 1;
523+
if (compactCalls === 1) return pendingCompact.promise;
524+
return {
525+
kind: 'finished' as const,
526+
turn: {
527+
sessionId,
528+
turnId: 'compact-turn-after-guard',
529+
runId: 'compact-run-after-guard',
530+
status: 'completed' as const,
531+
terminalEventId: 'compact-complete-after-guard',
532+
contextCompactionOutcome: { kind: 'unchanged' as const, reason: 'already_current' },
533+
},
534+
outcome: { kind: 'unchanged' as const, reason: 'already_current' },
535+
};
536+
},
537+
});
538+
539+
await commitIdleCompanion(rendered);
540+
let compactResult!: Promise<boolean>;
541+
await act(async () => {
542+
compactResult = rendered.send('/compact');
543+
await Promise.resolve();
544+
});
545+
await act(async () => {
546+
rendered.emit({
547+
type: 'complete',
548+
id: 'unrelated-complete',
549+
turnId: 'unrelated-turn',
550+
ts: 1,
551+
stopReason: 'end_turn',
552+
contextCompactionOutcome: { kind: 'unchanged', reason: 'already_current' },
553+
});
554+
await Promise.resolve();
555+
});
556+
557+
assert.equal(await rendered.send('/compact'), false);
558+
pendingCompact.resolve({
559+
kind: 'started',
560+
turn: {
561+
sessionId: 'side-conversation',
562+
turnId: 'compact-turn-unrelated-guard',
563+
runId: 'compact-run-unrelated-guard',
564+
status: 'running',
565+
},
566+
});
567+
assert.equal(await compactResult, true);
568+
assert.equal(compactCalls, 1);
569+
570+
await act(async () => {
571+
rendered.emit({
572+
type: 'complete',
573+
id: 'compact-complete',
574+
turnId: 'compact-turn-unrelated-guard',
575+
ts: 2,
576+
stopReason: 'end_turn',
577+
contextCompactionOutcome: { kind: 'unchanged', reason: 'already_current' },
578+
});
579+
await Promise.resolve();
580+
});
581+
assert.equal(await rendered.send('/compact'), true);
582+
assert.equal(compactCalls, 2);
583+
});
584+
585+
test('clears a failed companion compaction request so it can be retried', async () => {
586+
let compactCalls = 0;
587+
const rendered = await renderOwnershipProbe({
588+
compact: async (sessionId) => {
589+
compactCalls += 1;
590+
if (compactCalls === 1) throw new Error('temporary compact failure');
591+
return {
592+
kind: 'finished' as const,
593+
turn: {
594+
sessionId,
595+
turnId: 'compact-retry-turn',
596+
runId: 'compact-retry-run',
597+
status: 'completed' as const,
598+
terminalEventId: 'compact-retry-complete',
599+
contextCompactionOutcome: { kind: 'unchanged' as const, reason: 'already_current' },
600+
},
601+
outcome: { kind: 'unchanged' as const, reason: 'already_current' },
602+
};
603+
},
604+
});
605+
606+
await commitIdleCompanion(rendered);
607+
assert.equal(await rendered.send('/compact'), false);
608+
assert.equal(await rendered.send('/compact'), true);
609+
assert.equal(compactCalls, 2);
610+
});
611+
612+
test('rejects /compact while the companion is running without consuming staged quotes', async () => {
613+
const pendingSend = deferred<{ ok: true; turnId: string }>();
614+
let compactCalls = 0;
615+
const consumed: CompanionQuoteSnapshot[] = [];
616+
const rendered = await renderOwnershipProbe(
617+
{
618+
compact: async () => {
619+
compactCalls += 1;
620+
throw new Error('compact should not run while busy');
621+
},
622+
send: () => pendingSend.promise,
623+
},
624+
{
625+
pendingQuotes: [{ id: 'quote-1', value: { text: 'quoted context' } }],
626+
onQuotesConsumed: (snapshot) => consumed.push(snapshot),
627+
},
628+
);
629+
630+
let sendResult!: Promise<boolean>;
631+
await act(async () => {
632+
sendResult = rendered.send('ordinary question');
633+
await Promise.resolve();
634+
});
635+
assert.equal(await rendered.send('/compact'), false);
636+
assert.equal(compactCalls, 0);
637+
assert.deepEqual(consumed, []);
638+
639+
await act(async () => {
640+
pendingSend.resolve({ ok: true, turnId: 'running-turn' });
641+
assert.equal(await sendResult, true);
642+
});
643+
});
644+
645+
test('rejects /compact while the companion fork is preparing', async () => {
646+
const pendingFork = deferred<SessionSummary>();
647+
let compactCalls = 0;
648+
let branchStarted = false;
649+
const rendered = await renderOwnershipProbe({
650+
branchFromTurn: async () => {
651+
branchStarted = true;
652+
return { ok: true as const, session: await pendingFork.promise };
653+
},
654+
compact: async () => {
655+
compactCalls += 1;
656+
throw new Error('compact should not run before fork commit');
657+
},
658+
});
659+
660+
let sendResult!: Promise<boolean>;
661+
await act(async () => {
662+
sendResult = rendered.send('prepare pending fork');
663+
await Promise.resolve();
664+
});
665+
await waitUntil(() => branchStarted);
666+
assert.equal(await rendered.send('/compact'), false);
667+
assert.equal(compactCalls, 0);
668+
await act(async () => {
669+
pendingFork.resolve(session('side-conversation'));
670+
assert.equal(await sendResult, false);
671+
});
672+
});
673+
674+
test('rejects /compact for an archived companion fork without invoking Runtime Host', async () => {
675+
let compactCalls = 0;
676+
const rendered = await renderOwnershipProbe({
677+
branchFromTurn: async () => ({
678+
ok: true as const,
679+
session: session('side-conversation', { isArchived: true }),
680+
}),
681+
compact: async () => {
682+
compactCalls += 1;
683+
throw new Error('compact should not run for an archived fork');
684+
},
685+
});
686+
687+
await commitIdleCompanion(rendered);
688+
assert.equal(await rendered.send('/compact'), false);
689+
assert.equal(compactCalls, 0);
690+
});
691+
364692
test('does not fork on mount or when the source Session object refreshes', async () => {
365693
let branchCount = 0;
366694
const { container, root, services } = await renderProbe(
@@ -1610,6 +1938,7 @@ function QuoteCompanionOwnershipProbe(props: {
16101938
onSteer?: (steer: (text: string) => Promise<boolean>) => void;
16111939
onStop?: (stop: () => Promise<void>) => void;
16121940
onSetPermissionMode?: (set: (mode: PermissionMode) => Promise<boolean>) => void;
1941+
onContextCompactionError?: (sessionId: string, error: unknown) => void;
16131942
pendingQuotes?: readonly StagedCompanionQuote[];
16141943
onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void;
16151944
sourceSession?: SessionSummary;
@@ -1624,6 +1953,7 @@ function QuoteCompanionOwnershipProbe(props: {
16241953
locale: 'en',
16251954
onQuotesConsumed: props.onQuotesConsumed ?? (() => undefined),
16261955
confirmBypass: async () => true,
1956+
onContextCompactionError: props.onContextCompactionError,
16271957
});
16281958
props.onSend(companion.send);
16291959
props.onSteer?.(companion.steer);

0 commit comments

Comments
 (0)