Address compaction sidecar review feedback

This commit is contained in:
Robin Newhouse
2026-06-26 09:56:24 -07:00
parent 9ffcabcac1
commit 747aa65bd6
7 changed files with 188 additions and 83 deletions
@@ -83,12 +83,12 @@ function createConfig(): Config {
};
}
function createChatCommandState(): ChatCommandState {
function createChatCommandState(config = createConfig()): ChatCommandState {
return {
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: config.enableTools,
autoApproveTools: config.defaultToolAutoApprove,
cwd: config.cwd,
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
};
}
@@ -132,7 +132,7 @@ function makeSwitchToActModeTool(): AgentTool {
function makeManager() {
let startCount = 0;
const start = vi.fn(async () => {
const start = vi.fn(async (_input?: unknown) => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
@@ -192,18 +192,19 @@ function deferred<T>() {
async function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: {
config?: Config;
resumeSessionId?: string;
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
} = {},
) {
createCliCoreMock.mockResolvedValue(manager);
const config = createConfig();
const config = options.config ?? createConfig();
const { createInteractiveSessionRuntime } = await importRuntime();
return createInteractiveSessionRuntime({
config,
providerSettingsManager: createProviderSettingsManager(),
resumeSessionId: options.resumeSessionId,
chatCommandState: createChatCommandState(),
chatCommandState: createChatCommandState(config),
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
@@ -285,6 +286,7 @@ describe("createInteractiveSessionRuntime", () => {
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
@@ -358,6 +360,7 @@ describe("createInteractiveSessionRuntime", () => {
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
@@ -377,6 +380,21 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
});
it("rejects manual compact when compaction is disabled", async () => {
const manager = makeManager();
const config = createConfig();
config.compaction = { enabled: false };
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
await expect(runtime.compactCurrentSession()).rejects.toThrow(
"compaction is off",
);
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
});
it("carries compacted working context across mode-switch restarts", async () => {
const firstSessionId = "sess-mode-before";
const secondSessionId = "sess-mode-after";
@@ -439,6 +457,7 @@ describe("createInteractiveSessionRuntime", () => {
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
@@ -459,17 +478,16 @@ describe("createInteractiveSessionRuntime", () => {
const restartInput = manager.start.mock.calls[1]?.[0];
expect(restartInput).toMatchObject({
initialMessages: messages,
});
expect(restartInput).not.toHaveProperty("initialCompactionState");
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
secondSessionId,
expect.objectContaining({
conversation_id: secondSessionId,
initialCompactionState: expect.objectContaining({
source_message_count: messages.length,
messages: [summaryMessage, tailMessage],
system_prompt: "compacted system",
}),
});
expect(restartInput.initialCompactionState).not.toHaveProperty(
"conversation_id",
);
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
});
@@ -505,6 +523,7 @@ describe("createInteractiveSessionRuntime", () => {
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
@@ -536,13 +555,13 @@ describe("createInteractiveSessionRuntime", () => {
const upstreamBeforeTool = vi.fn(async () => ({
input: { text: "updated" },
}));
mockCreateRuntimeHooks.mockReturnValueOnce({
createRuntimeHooksMock.mockReturnValueOnce({
hooks: {
beforeTool: upstreamBeforeTool,
},
shutdown: vi.fn(async () => {}),
});
const runtime = makeRuntime(manager, {
const runtime = await makeRuntime(manager, {
resolveToolPolicy: (toolName) => ({
autoApprove: toolName === "echo",
}),
@@ -626,6 +645,7 @@ describe("createInteractiveSessionRuntime", () => {
resumeSessionId: "resumed-session",
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
@@ -700,6 +720,7 @@ describe("createInteractiveSessionRuntime", () => {
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
@@ -288,6 +288,25 @@ export function createInteractiveSessionRuntime(input: {
return (await sessionManager.readMessages(activeSessionId)) ?? [];
};
const readCompactionState = async (
sessionId: string,
): Promise<SessionCompactionState | undefined> => {
const manager = sessionManager;
if (!manager) {
return undefined;
}
try {
return await manager.readSessionCompactionState(sessionId);
} catch (error) {
input.config.logger?.log?.("Failed to read session compaction state", {
sessionId,
error,
severity: "warn",
});
return undefined;
}
};
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
if (missingSessionRecoveryPromise) {
return await missingSessionRecoveryPromise;
@@ -322,12 +341,10 @@ export function createInteractiveSessionRuntime(input: {
const readCurrentCompactionState = async (): Promise<
SessionCompactionState | undefined
> => {
if (!sessionManager || !activeSessionId) {
if (!activeSessionId) {
return undefined;
}
return await sessionManager
.readSessionCompactionState(activeSessionId)
.catch(() => undefined);
return await readCompactionState(activeSessionId);
};
const stopCurrentSession = async (): Promise<void> => {
@@ -386,26 +403,17 @@ export function createInteractiveSessionRuntime(input: {
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
await restartWithMessages(messages);
if (!projectedMessages || !sessionManager || !activeSessionId) {
return;
}
const reanchoredCompactionState = createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
conversationId: activeSessionId,
systemPrompt: compactionState?.system_prompt,
});
const updated = await sessionManager.updateSessionCompactionState(
activeSessionId,
reanchoredCompactionState,
await restartWithMessages(
messages,
undefined,
projectedMessages
? createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
})
: undefined,
);
if (!updated.updated) {
input.config.logger?.log?.(
"Skipped re-anchoring session compaction state after restart",
{ sessionId: activeSessionId },
);
}
};
const restartEmpty = async (): Promise<void> => {
@@ -519,9 +527,7 @@ export function createInteractiveSessionRuntime(input: {
if (messages.length === 0) {
throw new Error("Cannot fork an empty session.");
}
const compactionState = await manager
.readSessionCompactionState(forkedFromSessionId)
.catch(() => undefined);
const compactionState = await readCompactionState(forkedFromSessionId);
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
@@ -532,25 +538,17 @@ export function createInteractiveSessionRuntime(input: {
sourceSession: sessionRecord,
messages,
});
await startFreshSession(messages, forkMetadata);
if (projectedMessages && activeSessionId) {
const reanchoredCompactionState = createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
conversationId: activeSessionId,
systemPrompt: compactionState?.system_prompt,
});
const updated = await manager.updateSessionCompactionState(
activeSessionId,
reanchoredCompactionState,
);
if (!updated.updated) {
input.config.logger?.log?.(
"Skipped re-anchoring session compaction state after fork",
{ sessionId: activeSessionId },
);
}
}
await startFreshSession(
messages,
forkMetadata,
projectedMessages
? createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
})
: undefined,
);
return { forkedFromSessionId, newSessionId: activeSessionId };
};
@@ -575,6 +573,11 @@ export function createInteractiveSessionRuntime(input: {
workingContextMessagesAfter?: number;
compacted: boolean;
}> => {
if (input.config.compaction?.enabled === false) {
throw new Error(
"Cannot compact because compaction is off for this session.",
);
}
const manager = sessionManager;
const sourceSessionId = activeSessionId;
if (!manager || !sourceSessionId) {
+1 -1
View File
@@ -14,7 +14,7 @@ export function formatCompactionStatus(
return "No compaction needed.";
}
if (typeof result.workingContextMessagesAfter === "number") {
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; canonical history remains ${formatMessageCount(result.messagesAfter)}.`;
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
}
if (result.messagesBefore === result.messagesAfter) {
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
+11 -13
View File
@@ -206,39 +206,37 @@ new Agent({
For richer, host-side hook orchestration (15-stage `HookEngine`,
subprocess-backed hooks, MCP extensions), use `@cline/core`.
### Request Projection with `prepareTurn`
### Preparing Requests with `prepareTurn`
`prepareTurn` is a request projection hook. It runs during turn preparation and
may return a different message list or system prompt for the next provider
request:
`prepareTurn` runs before messages are sent to the provider. It can rewrite the
messages or system prompt for the next request:
```text
canonical transcript
saved transcript
|
| turn preparation
v
prepareTurn
|
v
provider request
prepared provider request
```
Returned messages affect only the provider request for the current model call.
They do not replace the runtime's canonical transcript, are not persisted as
session history, and are not returned from `AgentRunResult.messages`.
They do not replace saved history and are not returned from
`AgentRunResult.messages`.
```text
prepareTurn returns projected messages
prepareTurn returns prepared messages
|
+--> provider request: yes
+--> canonical transcript: no
+--> persisted transcript: no
+--> saved transcript: no
+--> AgentRunResult.messages: no
```
This is intentionally different from a transcript rewrite. Hosts that need
This is intentionally different from changing saved history. Hosts that need
durable redaction, normalization, or policy filtering must apply that change
before a message enters the agent's canonical transcript.
before a message enters the transcript.
### Plugins
@@ -36,16 +36,16 @@ function getCapabilityOwnerClientId(
ctx: HubTransportContext,
sessionId: string,
): string | undefined {
// Compaction sidecar access is intentionally tied to live hub session
// ownership, not mutable persisted metadata. Sessions restored after the
// hub forgets their live owner must be recreated or backfilled before using
// these owner-scoped sidecar endpoints.
// Sidecar access follows the live hub owner, not persisted metadata clients
// can replay or edit.
return ctx.sessionState.get(sessionId)?.createdByClientId;
}
function stripServerOwnedSessionMetadata(
metadata: Record<string, JsonValue | undefined> | undefined,
): Record<string, JsonValue | undefined> | undefined {
// Clients may echo old records back through session.update; keep ownership
// on the live hub state only.
if (!metadata || !(CAPABILITY_OWNER_METADATA_KEY in metadata)) {
return metadata;
}
@@ -65,7 +65,7 @@ function authorizeSessionCompactionAccess(input: {
return errorReply(
input.envelope,
"session_wrong_client",
`Session ${input.sessionId} has no authorized owner`,
`Session ${input.sessionId} has no owner`,
);
}
if (ownerClientId !== input.clientId) {
@@ -4337,6 +4337,82 @@ describe("LocalRuntimeHost", () => {
);
});
it("binds unowned initial compaction state to the started session", async () => {
const sessionId = "sess-compaction-initial";
const manifest = createManifest(sessionId);
const initialMessages: MessageWithMetadata[] = [
{ role: "user", content: "large source" },
];
const initialCompactionState = createSessionCompactionState({
sourceMessages: initialMessages,
compactedMessages: [{ role: "user", content: "summary" }],
updatedAt: "2026-01-01T00:00:00.000Z",
});
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-compaction-initial.json",
messagesPath: "/tmp/messages-compaction-initial.json",
manifest,
}),
persistSessionMessages: vi.fn(),
persistSessionCompactionState: vi.fn(),
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const run = vi.fn().mockResolvedValue(createResult());
const createAgent = vi.fn().mockReturnValue({
run,
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue(sessionId),
restore: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue(initialMessages),
messages: initialMessages,
});
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn(),
}),
},
createAgent: createAgent as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({
sessionId,
compaction: {
enabled: true,
strategy: "basic",
compact: vi.fn(),
},
}),
initialMessages,
initialCompactionState,
interactive: true,
}),
);
expect(sessionService.persistSessionCompactionState).toHaveBeenCalledWith(
sessionId,
expect.objectContaining({
conversation_id: sessionId,
messages: initialCompactionState.messages,
}),
);
});
it("does not project compaction state when compaction is disabled", async () => {
const sessionId = "sess-compaction-disabled";
const manifest = createManifest(sessionId);
@@ -485,9 +485,16 @@ export class LocalRuntimeHost implements RuntimeHost {
const explicitInitialCompactionState = startInput.initialCompactionState;
let activeSessionRef: ActiveSession | undefined;
const compact = createContextCompactionPrepareTurn(configWithProvider);
const initialCompactionState = compact
? (explicitInitialCompactionState ?? resumedCompactionState)
: undefined;
const rawInitialCompactionState =
explicitInitialCompactionState ?? resumedCompactionState;
const initialCompactionState =
compact && rawInitialCompactionState
? {
...rawInitialCompactionState,
conversation_id:
rawInitialCompactionState.conversation_id?.trim() || sessionId,
}
: undefined;
const prepareTurn = compact
? createCompactionStateAwarePrepareTurn({
compact,