Skip to content

Commit 03c5edc

Browse files
authored
feat(cloud-agent-next): add manual compact command (#3627)
* feat(cloud-agent-next): add manual compact command * fix(cloud-agent-next): harden compact completion delivery
1 parent a45a2c3 commit 03c5edc

25 files changed

Lines changed: 496 additions & 55 deletions

services/cloud-agent-next/src/kilo/wrapper-client.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ describe('WrapperClient', () => {
569569
command: 'compact',
570570
args: '--aggressive',
571571
messageId: 'msg_018f1e2d3c4bCommandWireAAA',
572+
agent: { model: { modelID: 'anthropic/claude-sonnet-4-20250514' } },
572573
autoCommit: true,
573574
condenseOnComplete: false,
574575
session: {
@@ -582,6 +583,7 @@ describe('WrapperClient', () => {
582583

583584
const execCall = (session.exec as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
584585
expect(execCall).toContain('"messageId":"msg_018f1e2d3c4bCommandWireAAA"');
586+
expect(execCall).toContain('"modelID":"anthropic/claude-sonnet-4-20250514"');
585587
expect(execCall).toContain('"autoCommit":true');
586588
expect(execCall).toContain('"condenseOnComplete":false');
587589
expect(execCall).toContain('"workerAuthToken":"tok_command"');

services/cloud-agent-next/src/persistence/CloudAgentSession.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -636,12 +636,12 @@ export class CloudAgentSession extends DurableObject<WorkerEnv> {
636636
terminalizeSessionMessageOnce: async (messageId, params, wrapperRunId) => {
637637
await this.ensureAcceptedMessageBeforeTerminal(messageId, wrapperRunId);
638638
await this.recordCorrelatedAgentActivity(messageId);
639-
await this.terminalizeSessionMessageOnce(messageId, {
640-
...params,
641-
...(params.kind === 'failed'
642-
? { failureStage: 'agent_activity', failureCode: 'assistant_error' }
643-
: {}),
644-
} as TerminalizeParams);
639+
await this.terminalizeSessionMessageOnce(
640+
messageId,
641+
params.kind === 'failed'
642+
? { ...params, failureStage: 'agent_activity', failureCode: 'assistant_error' }
643+
: params
644+
);
645645
},
646646
};
647647

services/cloud-agent-next/src/session-service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,11 @@ export class SessionService {
14851485
command: turn.command,
14861486
...(turn.arguments.length > 0 ? { args: turn.arguments } : {}),
14871487
messageId: turn.messageId,
1488+
agent: {
1489+
mode: promptAgent,
1490+
model: { modelID: agent.model },
1491+
...(agent.variant ? { variant: agent.variant } : {}),
1492+
},
14881493
...(finalization?.autoCommit !== undefined ? { autoCommit: finalization.autoCommit } : {}),
14891494
...(finalization?.condenseOnComplete !== undefined
14901495
? { condenseOnComplete: finalization.condenseOnComplete }

services/cloud-agent-next/src/session/ingest-handlers/commands-available.test.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it, vi } from 'vitest';
22
import { handleCommandsAvailable } from './commands-available.js';
3-
import { DEFAULT_SLASH_COMMANDS } from '../../shared/default-slash-commands.generated';
3+
import { commandsOrDefault } from '../../shared/slash-commands.js';
44

55
const silentLogger = { info: () => {}, warn: () => {} };
66

@@ -18,10 +18,13 @@ describe('handleCommandsAvailable', () => {
1818
);
1919

2020
expect(setAvailableCommands).toHaveBeenCalledTimes(1);
21-
expect(setAvailableCommands).toHaveBeenCalledWith([
22-
{ name: 'review', description: 'Review', hints: [] },
23-
{ name: 'init', hints: ['$ARGUMENTS'], source: 'command' },
24-
]);
21+
expect(setAvailableCommands).toHaveBeenCalledWith(
22+
expect.arrayContaining([
23+
expect.objectContaining({ name: 'review', description: 'Review', hints: [] }),
24+
expect.objectContaining({ name: 'init', hints: ['$ARGUMENTS'], source: 'command' }),
25+
{ name: 'compact', description: 'compact the current session context', hints: [] },
26+
])
27+
);
2528
});
2629

2730
it('drops items missing a name without rejecting the whole event', async () => {
@@ -31,7 +34,12 @@ describe('handleCommandsAvailable', () => {
3134
{ setAvailableCommands, logger: silentLogger }
3235
);
3336

34-
expect(setAvailableCommands).toHaveBeenCalledWith([{ name: 'ok', hints: [] }]);
37+
expect(setAvailableCommands).toHaveBeenCalledWith(
38+
expect.arrayContaining([
39+
expect.objectContaining({ name: 'ok', hints: [] }),
40+
{ name: 'compact', description: 'compact the current session context', hints: [] },
41+
])
42+
);
3543
});
3644

3745
it('warns and skips when commands array is missing', async () => {
@@ -60,7 +68,7 @@ describe('handleCommandsAvailable', () => {
6068
await handleCommandsAvailable({ commands: [] }, { setAvailableCommands, logger: silentLogger });
6169

6270
expect(setAvailableCommands).toHaveBeenCalledTimes(1);
63-
expect(setAvailableCommands).toHaveBeenCalledWith(DEFAULT_SLASH_COMMANDS);
71+
expect(setAvailableCommands).toHaveBeenCalledWith(commandsOrDefault(undefined));
6472
});
6573

6674
it('persists defaults when all items fail validation', async () => {
@@ -71,6 +79,6 @@ describe('handleCommandsAvailable', () => {
7179
);
7280

7381
expect(setAvailableCommands).toHaveBeenCalledTimes(1);
74-
expect(setAvailableCommands).toHaveBeenCalledWith(DEFAULT_SLASH_COMMANDS);
82+
expect(setAvailableCommands).toHaveBeenCalledWith(commandsOrDefault(undefined));
7583
});
7684
});

services/cloud-agent-next/src/session/message-settlement-outbox.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,28 @@ describe('MessageSettlementOutbox', () => {
198198
});
199199
});
200200

201+
it('persists manual compact terminalization and emits one completion event', async () => {
202+
const harness = createHarness();
203+
await putSessionMessageState(harness.storage, acceptedMessageState(firstMessageId));
204+
205+
const result = await harness.outbox.terminalizeSessionMessageOnce(firstMessageId, {
206+
kind: 'completed',
207+
completionSource: 'manual_compact_summarize',
208+
});
209+
210+
expect(result.changed).toBe(true);
211+
expect(await getSessionMessageState(harness.storage, firstMessageId)).toMatchObject({
212+
status: 'completed',
213+
completionSource: 'manual_compact_summarize',
214+
});
215+
expect(harness.events).toHaveLength(1);
216+
expect(JSON.parse(harness.events[0].payload)).toMatchObject({
217+
messageId: firstMessageId,
218+
status: 'completed',
219+
completionSource: 'manual_compact_summarize',
220+
});
221+
});
222+
201223
it('dispatches one web-session push using message identity and assistant text', async () => {
202224
const harness = createHarness({
203225
metadata: pushMetadata,

services/cloud-agent-next/src/session/session-message-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type SessionMessageStatus = 'queued' | 'accepted' | 'completed' | 'failed
1212

1313
export type SessionMessageCompletionSource =
1414
| 'assistant_message_event'
15+
| 'manual_compact_summarize'
1516
| 'idle_reconciliation'
1617
| 'wrapper_failure'
1718
| 'interrupt'
@@ -167,6 +168,7 @@ export const SessionMessageStateSchema = z
167168
completionSource: z
168169
.enum([
169170
'assistant_message_event',
171+
'manual_compact_summarize',
170172
'idle_reconciliation',
171173
'wrapper_failure',
172174
'interrupt',

services/cloud-agent-next/src/shared/protocol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { SlashCommandInfo } from './slash-commands.js';
55
*
66
* From wrapper -> DO:
77
* started, kilocode, output, status, heartbeat, pong, error, interrupted, complete, wrapper_resumed,
8-
* autocommit_started, autocommit_completed
8+
* autocommit_started, autocommit_completed, cloud.message.completed
99
*
1010
* From DO -> /stream clients:
1111
* All of the above, plus wrapper_disconnected, wrapper_reconnected, preparing,

services/cloud-agent-next/src/shared/slash-commands.test.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,34 @@ describe('toSlashCommandInfo', () => {
9494
});
9595

9696
describe('commandsOrDefault', () => {
97-
it('returns the provided list when non-empty', () => {
97+
it('returns live commands with session actions when non-empty', () => {
9898
const live = [{ name: 'live', hints: [] }];
99+
expect(commandsOrDefault(live)).toEqual([
100+
{ name: 'live', hints: [] },
101+
{ name: 'compact', description: 'compact the current session context', hints: [] },
102+
]);
103+
});
104+
105+
it('does not duplicate live session actions', () => {
106+
const live = [{ name: 'compact', description: 'live compact', hints: [] }];
99107
expect(commandsOrDefault(live)).toBe(live);
100108
});
101109

102110
it('returns defaults for undefined', () => {
103-
expect(commandsOrDefault(undefined)).toEqual(DEFAULT_SLASH_COMMANDS);
111+
expect(commandsOrDefault(undefined)).toEqual(
112+
expect.arrayContaining([
113+
...DEFAULT_SLASH_COMMANDS,
114+
{ name: 'compact', description: 'compact the current session context', hints: [] },
115+
])
116+
);
104117
});
105118

106119
it('returns defaults for null', () => {
107-
expect(commandsOrDefault(null)).toEqual(DEFAULT_SLASH_COMMANDS);
120+
expect(commandsOrDefault(null)).toEqual(commandsOrDefault(undefined));
108121
});
109122

110123
it('returns defaults for empty array', () => {
111-
expect(commandsOrDefault([])).toEqual(DEFAULT_SLASH_COMMANDS);
124+
expect(commandsOrDefault([])).toEqual(commandsOrDefault(undefined));
112125
});
113126

114127
it('default commands are non-empty and validate', () => {

services/cloud-agent-next/src/shared/slash-commands.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import {
66

77
export { DEFAULT_SLASH_COMMANDS, DEFAULT_SLASH_COMMANDS_SOURCE, type SlashCommandInfo };
88

9+
const SESSION_SLASH_COMMANDS = [
10+
{
11+
name: 'compact',
12+
description: 'compact the current session context',
13+
hints: [],
14+
},
15+
] satisfies SlashCommandInfo[];
16+
917
/** Parsed result of "/name rest of the line" from the chat composer. */
1018
export type SlashCommandInvocation = {
1119
command: string;
@@ -55,10 +63,19 @@ export function toSlashCommandInfo(raw: unknown): SlashCommandInfo | null {
5563
* Return the provided command list when it is non-empty, otherwise fall back
5664
* to the hardcoded default catalog. Used both server-side (DO storage) and
5765
* client-side (hook) so empty always means "defaults" rather than "none yet".
66+
* Session actions such as compaction are local Kilo actions rather than
67+
* registered prompt commands, so append them when the live catalog omits them.
5868
*/
5969
export function commandsOrDefault(
6070
commands: SlashCommandInfo[] | null | undefined
6171
): SlashCommandInfo[] {
62-
if (commands && commands.length > 0) return commands;
63-
return DEFAULT_SLASH_COMMANDS;
72+
return withSessionSlashCommands(
73+
commands && commands.length > 0 ? commands : DEFAULT_SLASH_COMMANDS
74+
);
75+
}
76+
77+
function withSessionSlashCommands(commands: SlashCommandInfo[]): SlashCommandInfo[] {
78+
const names = new Set(commands.map(command => command.name));
79+
const missing = SESSION_SLASH_COMMANDS.filter(command => !names.has(command.name));
80+
return missing.length > 0 ? [...commands, ...missing] : commands;
6481
}

services/cloud-agent-next/src/shared/wrapper-bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export type WrapperCommandRequest = {
100100
command: string;
101101
args?: string;
102102
messageId: string;
103+
agent?: WrapperPromptAgent;
103104
autoCommit?: boolean;
104105
condenseOnComplete?: boolean;
105106
session: WrapperSessionBinding;

0 commit comments

Comments
 (0)