mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 141f294c82 | |||
| 30b652ec11 | |||
| c267ef4d6e | |||
| b64923d650 | |||
| 801462bb2c | |||
| a5b98c974c | |||
| 4bd4b3cf6b | |||
| 031d6dc269 | |||
| ce61d741ee | |||
| bfc522989d | |||
| b47de6eff6 | |||
| 420b203e96 | |||
| f4041977ad | |||
| 2d99576ad8 | |||
| 6c46c61801 | |||
| 964a14b428 | |||
| 8234422493 | |||
| 8ab6650965 | |||
| cae2db3345 | |||
| 00c7c2373c | |||
| f24e8f8f02 | |||
| 9dfe5513a2 | |||
| c1c47a94fe | |||
| 2b20623f08 | |||
| 7751d75b5c | |||
| 4f3f8e9f53 | |||
| e3ab67d34b | |||
| 5576e873f8 |
@@ -126,7 +126,8 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
@@ -157,7 +158,8 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
@@ -174,7 +176,8 @@ describe("compactInteractiveMessages", () => {
|
||||
messages,
|
||||
});
|
||||
|
||||
const compactedTextLength = result.messages.reduce(
|
||||
const compactedMessages = result.compactionState?.messages ?? [];
|
||||
const compactedTextLength = compactedMessages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(typeof message.content === "string" ? message.content.length : 0),
|
||||
@@ -182,8 +185,9 @@ describe("compactInteractiveMessages", () => {
|
||||
);
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages.length).toBeGreaterThan(1);
|
||||
expect(result.messages.length).toBeLessThan(messages.length);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
@@ -214,8 +218,9 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toHaveLength(messages.length);
|
||||
expect(result.messages[0]?.content).toBe(
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toHaveLength(messages.length);
|
||||
expect(result.compactionState?.messages[0]?.content).toBe(
|
||||
"same count but content should be trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
type ReasoningSettings,
|
||||
type SessionCompactionState,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
@@ -52,7 +54,12 @@ export async function compactInteractiveMessages(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
}): Promise<{ compacted: boolean; messages: Message[] }> {
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
compacted: boolean;
|
||||
canonicalMessages: Message[];
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
@@ -81,8 +88,11 @@ export async function compactInteractiveMessages(input: {
|
||||
{ mode: "manual" },
|
||||
);
|
||||
if (!compact) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
// Manual compaction intentionally summarizes the full canonical transcript
|
||||
// instead of reusing a prior sidecar summary, which avoids summary-of-summary
|
||||
// drift across repeated `/compact` calls.
|
||||
const result = await compact({
|
||||
agentId: "cli",
|
||||
conversationId: input.sessionId,
|
||||
@@ -90,7 +100,7 @@ export async function compactInteractiveMessages(input: {
|
||||
iteration: 0,
|
||||
messages: input.messages,
|
||||
apiMessages: input.messages,
|
||||
abortSignal: new AbortController().signal,
|
||||
abortSignal: input.abortSignal ?? new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
model: {
|
||||
@@ -103,8 +113,17 @@ export async function compactInteractiveMessages(input: {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
if (!result?.messages) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
return { compacted: true, messages: result.messages };
|
||||
return {
|
||||
compacted: true,
|
||||
canonicalMessages: input.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,89 +1,124 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
|
||||
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
|
||||
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
|
||||
function makeConfig(): Config {
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
enableAgentTeams: true,
|
||||
verbose: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
function createChatCommandState(): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
enableTools: true,
|
||||
autoApproveTools: true,
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
return {
|
||||
getProviderSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ProviderSettingsManager;
|
||||
}
|
||||
|
||||
function createManifest(sessionId: string): SessionManifest {
|
||||
return {
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: 1,
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
cwd: "/tmp/project",
|
||||
workspace_root: "/tmp/project",
|
||||
enable_tools: true,
|
||||
enable_spawn: true,
|
||||
enable_teams: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function importRuntime() {
|
||||
return await import("./session-runtime");
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
@@ -95,14 +130,14 @@ function makeSwitchToActModeTool(): AgentTool {
|
||||
|
||||
function makeManager() {
|
||||
let startCount = 0;
|
||||
const start = vi.fn(async (_input?: unknown) => {
|
||||
const start = vi.fn(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -114,6 +149,8 @@ function makeManager() {
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
@@ -133,7 +170,7 @@ function makeTurnResult() {
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
@@ -150,43 +187,322 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
async function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
it("manual compact updates the active session sidecar without restarting", async () => {
|
||||
const sessionId = "sess-active";
|
||||
const messages = [
|
||||
{ id: "u1", role: "user" as const, content: "hello" },
|
||||
{ id: "a1", role: "assistant" as const, content: "world" },
|
||||
];
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
compactInteractiveMessagesMock.mockResolvedValue({
|
||||
compacted: true,
|
||||
canonicalMessages: messages,
|
||||
compactionState,
|
||||
});
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.compactCurrentSession();
|
||||
|
||||
expect(result).toEqual({
|
||||
messagesBefore: messages.length,
|
||||
messagesAfter: messages.length,
|
||||
workingContextMessagesAfter: compactionState.messages.length,
|
||||
compacted: true,
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(manager.stop).not.toHaveBeenCalled();
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
}),
|
||||
providerSettingsManager: expect.objectContaining({
|
||||
getProviderSettings: expect.any(Function),
|
||||
}),
|
||||
sessionId,
|
||||
messages,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
compactionState,
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("rejects manual compact while the active session is running", async () => {
|
||||
const sessionId = "sess-running";
|
||||
const messages = [{ role: "user" as const, content: "hello" }];
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
status: "running",
|
||||
}),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"Cannot compact while the current turn is running",
|
||||
);
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
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";
|
||||
const prefixMessage = {
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: "large original",
|
||||
};
|
||||
const tailMessage = {
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: "new canonical tail",
|
||||
};
|
||||
const messages = [prefixMessage, tailMessage];
|
||||
const summaryMessage = {
|
||||
id: "summary",
|
||||
role: "user" as const,
|
||||
content: "summary",
|
||||
};
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: [prefixMessage],
|
||||
compactedMessages: [summaryMessage],
|
||||
conversationId: firstSessionId,
|
||||
systemPrompt: "compacted system",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: firstSessionId,
|
||||
manifest: createManifest(firstSessionId),
|
||||
manifestPath: "/tmp/session-before.json",
|
||||
messagesPath: "/tmp/session-before.messages.json",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: secondSessionId,
|
||||
manifest: createManifest(secondSessionId),
|
||||
manifestPath: "/tmp/session-after.json",
|
||||
messagesPath: "/tmp/session-after.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.applyMode("plan");
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
|
||||
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
|
||||
firstSessionId,
|
||||
);
|
||||
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
|
||||
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,
|
||||
source_message_count: messages.length,
|
||||
messages: [summaryMessage, tailMessage],
|
||||
system_prompt: "compacted system",
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
@@ -197,7 +513,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
@@ -206,14 +522,50 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: "resumed-session",
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
@@ -221,16 +573,14 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
config: expect.objectContaining({ sessionId: "resumed-session" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
@@ -247,8 +597,45 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
@@ -270,7 +657,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
@@ -307,7 +694,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -79,6 +82,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
let manualCompactionAbortController: AbortController | undefined;
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
@@ -164,6 +168,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
@@ -173,6 +178,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -281,6 +287,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const readCurrentCompactionState = async (): Promise<
|
||||
SessionCompactionState | undefined
|
||||
> => {
|
||||
if (!sessionManager || !activeSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return await sessionManager
|
||||
.readSessionCompactionState(activeSessionId)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -318,6 +335,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
@@ -325,12 +343,37 @@ export function createInteractiveSessionRuntime(input: {
|
||||
startupError = undefined;
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
await startFreshSession(messages, sessionMetadata, initialCompactionState);
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const messages = await readCurrentMessages();
|
||||
const [messages, compactionState] = await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
]);
|
||||
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,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
input.config.logger?.log?.(
|
||||
"Skipped re-anchoring session compaction state after restart",
|
||||
{ sessionId: activeSessionId },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
@@ -444,6 +487,12 @@ 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 projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await manager.stop(forkedFromSessionId);
|
||||
const forkMetadata = buildForkSessionMetadata({
|
||||
forkedFromSessionId,
|
||||
@@ -452,6 +501,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
};
|
||||
|
||||
@@ -473,22 +540,41 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
if (!sessionManager) {
|
||||
const manager = sessionManager;
|
||||
const sourceSessionId = activeSessionId;
|
||||
if (!manager || !sourceSessionId) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const messages = await readCurrentMessages();
|
||||
const messages = (await manager.readMessages(sourceSessionId)) ?? [];
|
||||
const messagesBefore = messages.length;
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: activeSessionId,
|
||||
messages,
|
||||
});
|
||||
const sessionRecord = await manager.get(sourceSessionId);
|
||||
if (sessionRecord?.status === "running") {
|
||||
throw new Error(
|
||||
"Cannot compact while the current turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof compactInteractiveMessages>>;
|
||||
const abortController = new AbortController();
|
||||
manualCompactionAbortController = abortController;
|
||||
try {
|
||||
result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: sourceSessionId,
|
||||
messages,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
} finally {
|
||||
if (manualCompactionAbortController === abortController) {
|
||||
manualCompactionAbortController = undefined;
|
||||
}
|
||||
}
|
||||
if (!result.compacted) {
|
||||
return {
|
||||
messagesBefore,
|
||||
@@ -496,10 +582,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
await restartWithMessages(result.messages);
|
||||
if (!result.compactionState) {
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: messagesBefore,
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
const updated = await manager.updateSessionCompactionState(
|
||||
sourceSessionId,
|
||||
result.compactionState,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
throw new Error("Compaction could not be saved. Try again.");
|
||||
}
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: result.messages.length,
|
||||
messagesAfter: result.canonicalMessages.length,
|
||||
workingContextMessagesAfter: result.compactionState?.messages.length,
|
||||
compacted: true,
|
||||
};
|
||||
};
|
||||
@@ -586,6 +686,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
abortRequested = true;
|
||||
markAbortInProgress();
|
||||
manualCompactionAbortController?.abort(
|
||||
new Error("Interactive runtime abort requested"),
|
||||
);
|
||||
sessionManager
|
||||
.abort(activeSessionId, new Error("Interactive runtime abort requested"))
|
||||
.catch(() => {});
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("formatCompactionStatus", () => {
|
||||
messagesAfter: 300,
|
||||
compacted: true,
|
||||
}),
|
||||
).toBe("Compacted context; message count stayed at 300.");
|
||||
).toBe("Compacted context; message count stayed at 300 messages.");
|
||||
});
|
||||
|
||||
it("reports empty sessions separately", () => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ResumedSessionResult {
|
||||
export interface InteractiveCompactionResult {
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
@@ -9,8 +13,11 @@ export function formatCompactionStatus(
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; canonical history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
describe("resolveStatusNoticeLabel", () => {
|
||||
it("maps compaction status reasons to stable labels", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "auto-compacting",
|
||||
reason: "auto_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("auto-compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "manual",
|
||||
reason: "manual_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction-budget-adjusted",
|
||||
reason: "compaction_budget_emergency",
|
||||
} as AgentEvent),
|
||||
).toBe("context budget adjusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
|
||||
|
||||
@@ -27,8 +27,13 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
switch (event.reason) {
|
||||
case "auto_compaction":
|
||||
return "auto-compacting";
|
||||
case "manual_compaction":
|
||||
return "compacting";
|
||||
case "compaction_budget_emergency":
|
||||
return "context budget adjusted";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
|
||||
+6
-1
@@ -323,15 +323,20 @@ Context compaction is owned by `core`.
|
||||
|
||||
- `@cline/agents` owns the generic turn-preparation seam:
|
||||
- run normal lifecycle hooks
|
||||
- allow hosts to rewrite message history or system prompt before the provider call
|
||||
- allow hosts to project message history or system prompt before the provider call
|
||||
- keep its canonical runtime transcript append-only when a projection is returned
|
||||
- `@cline/core` owns compaction policy:
|
||||
- inject a prepare-turn pipeline for root sessions
|
||||
- choose between built-in strategies through a registry map
|
||||
- persist the latest compacted working context as a session compaction artifact
|
||||
- keep compaction logic out of the low-level agent message builder
|
||||
|
||||
Design implications:
|
||||
|
||||
- compaction is a context-pipeline concern owned by `core`
|
||||
- canonical session history lives in the session messages artifact at full fidelity; compaction state lives separately in `${sessionId}.compaction.json`
|
||||
- resume loads the canonical transcript for history/debugging and, when present, reuses the latest compaction state only after validating a hash of the canonical prefix covered by that state; valid state is projected by appending canonical messages written after the compaction boundary
|
||||
- sessions that were already persisted with compacted messages before this model are best-effort only because the omitted original transcript is not recoverable from the compacted artifact
|
||||
- `agents` stays focused on the stateless loop and provider/tool orchestration
|
||||
- delegated/subagent flows should inherit compaction behavior through core session config, not through a separate agent-level compaction hook surface
|
||||
|
||||
|
||||
@@ -902,7 +902,7 @@ describe("AgentRuntime", () => {
|
||||
expect(model.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs prepareTurn before beforeModel and persists rewritten messages", async () => {
|
||||
it("runs prepareTurn before beforeModel without overwriting canonical messages", async () => {
|
||||
const compactedMessage: AgentMessage = {
|
||||
id: "msg_compacted",
|
||||
role: "user",
|
||||
@@ -955,7 +955,10 @@ describe("AgentRuntime", () => {
|
||||
expect(prepareTurn).toHaveBeenCalledTimes(1);
|
||||
expect(beforeModel).toHaveBeenCalledTimes(1);
|
||||
expect(notices).toEqual(["auto-compacting"]);
|
||||
expect(result.messages[0]).toEqual(compactedMessage);
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "large context" }],
|
||||
});
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(model.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -753,8 +753,15 @@ export class AgentRuntime {
|
||||
};
|
||||
|
||||
if (this.state.iteration > 1) {
|
||||
if (await this.consumePendingUserMessage()) {
|
||||
request = { ...request, messages: cloneMessages(this.state.messages) };
|
||||
const pendingUserMessage = await this.consumePendingUserMessage();
|
||||
if (pendingUserMessage) {
|
||||
request = {
|
||||
...request,
|
||||
messages: [
|
||||
...request.messages,
|
||||
...cloneMessages([pendingUserMessage]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,7 +1014,6 @@ export class AgentRuntime {
|
||||
let next = request;
|
||||
if (result.messages) {
|
||||
const preparedMessages = cloneMessages(result.messages);
|
||||
this.state.messages = preparedMessages;
|
||||
next = { ...next, messages: cloneMessages(preparedMessages) };
|
||||
}
|
||||
if (result.systemPrompt !== undefined) {
|
||||
@@ -1016,14 +1022,14 @@ export class AgentRuntime {
|
||||
return next;
|
||||
}
|
||||
|
||||
private async consumePendingUserMessage(): Promise<boolean> {
|
||||
private async consumePendingUserMessage(): Promise<AgentMessage | undefined> {
|
||||
const consumePendingUserMessage = this.config.consumePendingUserMessage;
|
||||
if (!consumePendingUserMessage) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
const pending = (await consumePendingUserMessage())?.trim();
|
||||
if (!pending) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
const message = createMessage("user", [{ type: "text", text: pending }]);
|
||||
this.state.messages.push(message);
|
||||
@@ -1032,7 +1038,7 @@ export class AgentRuntime {
|
||||
snapshot: this.snapshot(),
|
||||
message,
|
||||
});
|
||||
return true;
|
||||
return message;
|
||||
}
|
||||
|
||||
private async updateUsage(usage: Partial<AgentUsage>): Promise<void> {
|
||||
|
||||
@@ -464,6 +464,18 @@ export class ClineCore {
|
||||
*/
|
||||
update: RuntimeHost["updateSession"] = (...args) =>
|
||||
this.host.updateSession(...args);
|
||||
/**
|
||||
* Stores the compacted working-context state for an existing session.
|
||||
*/
|
||||
updateSessionCompactionState: RuntimeHost["updateSessionCompactionState"] = (
|
||||
...args
|
||||
) => this.host.updateSessionCompactionState(...args);
|
||||
/**
|
||||
* Reads the compacted working-context sidecar for a session, if one exists.
|
||||
*/
|
||||
readSessionCompactionState: RuntimeHost["readSessionCompactionState"] = (
|
||||
...args
|
||||
) => this.host.readSessionCompactionState(...args);
|
||||
/**
|
||||
* Reads message history for a session.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
CoreCompactionSummarizerConfig,
|
||||
} from "../../types/config";
|
||||
import type { ProviderConfig } from "../../types/provider-settings";
|
||||
import {
|
||||
buildBudgetProjection,
|
||||
type BudgetProjectionResult,
|
||||
} from "./budget-projection";
|
||||
import {
|
||||
buildSummaryMessage,
|
||||
buildSummaryRequest,
|
||||
@@ -20,6 +24,43 @@ import {
|
||||
serializeConversation,
|
||||
} from "./compaction-shared";
|
||||
|
||||
const MIN_AGENTIC_SUMMARY_INPUT_TOKENS = 1_024;
|
||||
|
||||
function resolveProviderMaxInputTokens(
|
||||
providerConfig: ProviderConfig,
|
||||
): number | undefined {
|
||||
const explicit = providerConfig.maxInputTokens;
|
||||
if (typeof explicit === "number" && Number.isFinite(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
const modelInfoLimit =
|
||||
providerConfig.modelInfo?.maxInputTokens ??
|
||||
providerConfig.modelInfo?.contextWindow;
|
||||
if (typeof modelInfoLimit === "number" && Number.isFinite(modelInfoLimit)) {
|
||||
return modelInfoLimit;
|
||||
}
|
||||
const knownModelInfo = providerConfig.knownModels?.[providerConfig.modelId];
|
||||
const knownModelLimit =
|
||||
knownModelInfo?.maxInputTokens ?? knownModelInfo?.contextWindow;
|
||||
if (typeof knownModelLimit === "number" && Number.isFinite(knownModelLimit)) {
|
||||
return knownModelLimit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAgenticSummaryInputBudget(options: {
|
||||
messages: CoreCompactionContext["messages"];
|
||||
targetTokens: number;
|
||||
estimateMessageTokens: EstimateMessageTokens;
|
||||
}): BudgetProjectionResult {
|
||||
return buildBudgetProjection({
|
||||
messages: options.messages,
|
||||
targetTokens: Math.max(1, options.targetTokens),
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
}
|
||||
|
||||
async function generateSummary(options: {
|
||||
providerConfig: ProviderConfig;
|
||||
request: string;
|
||||
@@ -93,7 +134,78 @@ export async function runAgenticCompaction(options: {
|
||||
}
|
||||
|
||||
const fileOps = extractFileOps(messagesToSummarize);
|
||||
const conversationText = serializeConversation(newMessagesToFold);
|
||||
const summarizerProviderConfig = resolveSummarizerConfig({
|
||||
activeProviderConfig: options.providerConfig,
|
||||
summarizer: options.summarizer,
|
||||
});
|
||||
const resolvedSummarizerInputLimit = resolveProviderMaxInputTokens(
|
||||
summarizerProviderConfig,
|
||||
);
|
||||
const canUseActiveContextLimit = options.summarizer === undefined;
|
||||
const activeCompactionInputLimit = Math.max(
|
||||
options.context.maxInputTokens,
|
||||
options.context.triggerTokens,
|
||||
MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
|
||||
);
|
||||
if (
|
||||
resolvedSummarizerInputLimit === undefined &&
|
||||
!canUseActiveContextLimit
|
||||
) {
|
||||
options.logger?.log(
|
||||
"Agentic compaction summarizer has no known input limit; using conservative summary budget",
|
||||
{
|
||||
severity: "warn",
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
fallbackInputLimit: MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
|
||||
},
|
||||
);
|
||||
}
|
||||
const summarizerInputLimit =
|
||||
resolvedSummarizerInputLimit ??
|
||||
(canUseActiveContextLimit
|
||||
? activeCompactionInputLimit
|
||||
: MIN_AGENTIC_SUMMARY_INPUT_TOKENS);
|
||||
const summaryRequestOverheadTokens = estimateTokens(
|
||||
buildSummaryRequest({
|
||||
previousSummary,
|
||||
conversationText: "",
|
||||
fileOps,
|
||||
}).length,
|
||||
);
|
||||
const availableSummaryInputTokens =
|
||||
summarizerInputLimit - summaryRequestOverheadTokens;
|
||||
if (availableSummaryInputTokens <= 0) {
|
||||
options.logger?.debug("Skipped agentic compaction: summarizer budget exhausted", {
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
summarizerInputLimit,
|
||||
summaryRequestOverheadTokens,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const summaryInputBudget = buildAgenticSummaryInputBudget({
|
||||
messages: newMessagesToFold,
|
||||
targetTokens: availableSummaryInputTokens,
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
if (summaryInputBudget.status === "failed") {
|
||||
options.logger?.log(
|
||||
"Skipped agentic compaction: summary input budget failed",
|
||||
{
|
||||
severity: "warn",
|
||||
budgetWarnings: summaryInputBudget.warnings.map(
|
||||
(warning) => warning.code,
|
||||
),
|
||||
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
|
||||
targetTokens: availableSummaryInputTokens,
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const conversationText = serializeConversation(summaryInputBudget.messages);
|
||||
const summaryRequest = buildSummaryRequest({
|
||||
previousSummary,
|
||||
conversationText,
|
||||
@@ -108,14 +220,20 @@ export async function runAgenticCompaction(options: {
|
||||
summaryRequestChars: summaryRequest.length,
|
||||
summaryRequestEstimatedTokens: estimateTokens(summaryRequest.length),
|
||||
newMessagesJsonChars: safeJsonSize(newMessagesToFold),
|
||||
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
|
||||
summaryInputActions: summaryInputBudget.actions.length,
|
||||
summaryInputWarnings: summaryInputBudget.warnings.map(
|
||||
(warning) => warning.code,
|
||||
),
|
||||
summaryRequestOverheadTokens,
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
summarizerInputLimit,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
triggerTokens: options.context.triggerTokens,
|
||||
});
|
||||
const rawSummary = await generateSummary({
|
||||
providerConfig: resolveSummarizerConfig({
|
||||
activeProviderConfig: options.providerConfig,
|
||||
summarizer: options.summarizer,
|
||||
}),
|
||||
providerConfig: summarizerProviderConfig,
|
||||
request: summaryRequest,
|
||||
logger: options.logger,
|
||||
});
|
||||
@@ -149,5 +267,13 @@ export async function runAgenticCompaction(options: {
|
||||
tokensAfter,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
return { messages: resultMessages };
|
||||
return {
|
||||
messages: resultMessages,
|
||||
budget: {
|
||||
policyIntent: "agentic_summary",
|
||||
actionCount: summaryInputBudget.actions.length,
|
||||
warningCount: summaryInputBudget.warnings.length,
|
||||
liveTailHandling: summaryInputBudget.liveTailHandling,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CoreCompactionContext,
|
||||
CoreCompactionResult,
|
||||
} from "../../types/config";
|
||||
import { buildBudgetProjection } from "./budget-projection";
|
||||
import {
|
||||
type EstimateMessageTokens,
|
||||
findFirstUserMessageIndex,
|
||||
@@ -390,7 +391,26 @@ export function runBasicCompaction(options: {
|
||||
...candidates.map((candidate) => candidate.message),
|
||||
...protectedTail,
|
||||
];
|
||||
if (!haveMessagesChanged(options.context.messages, nextMessages)) {
|
||||
const budgeted = buildBudgetProjection({
|
||||
messages: nextMessages,
|
||||
targetTokens,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
// This final projection owns the hard output budget. Unlike the earlier
|
||||
// basic candidate passes, it may drop the original first-user message when
|
||||
// preserving the latest typed prompt and coherent tool closures requires it.
|
||||
if (budgeted.status === "failed") {
|
||||
options.logger?.debug("Basic compaction returned best-effort projection", {
|
||||
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
|
||||
projectedTokens: budgeted.estimatedTokens,
|
||||
targetTokens,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
}
|
||||
const resultMessages = budgeted.messages;
|
||||
|
||||
if (!haveMessagesChanged(options.context.messages, resultMessages)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -402,18 +422,29 @@ export function runBasicCompaction(options: {
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
const afterTokens = getTotalTokens(
|
||||
nextMessages,
|
||||
resultMessages,
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
options.logger?.debug("Performed basic compaction", {
|
||||
messagesBefore: options.context.messages.length,
|
||||
messagesAfter: nextMessages.length,
|
||||
messagesRemoved: options.context.messages.length - nextMessages.length,
|
||||
messagesAfter: resultMessages.length,
|
||||
messagesRemoved: options.context.messages.length - resultMessages.length,
|
||||
tokensBefore: beforeTokens,
|
||||
tokensAfter: afterTokens,
|
||||
budgetStatus: budgeted.status,
|
||||
budgetActions: budgeted.actions.length,
|
||||
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
|
||||
targetTokens,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
|
||||
return { messages: nextMessages };
|
||||
return {
|
||||
messages: resultMessages,
|
||||
budget: {
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: budgeted.actions.length,
|
||||
warningCount: budgeted.warnings.length,
|
||||
liveTailHandling: budgeted.liveTailHandling,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
export type {
|
||||
BlockBudgetClass,
|
||||
BudgetAction,
|
||||
BudgetActionKind,
|
||||
BudgetActionReason,
|
||||
BudgetPath,
|
||||
BudgetPolicyIntent,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
ContentBlockBudgetClassification,
|
||||
LiveTailHandling,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
|
||||
const estimateChars = (message: MessageWithMetadata) =>
|
||||
JSON.stringify(message).length;
|
||||
|
||||
describe("buildBudgetProjection", () => {
|
||||
it("fails explicitly for impossible budgets", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [{ role: "user", content: "keep me" }],
|
||||
targetTokens: 0,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.warnings[0]?.code).toBe("budget_impossible");
|
||||
});
|
||||
|
||||
it("drops unsafe image and redacted thinking blocks instead of truncating them", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "old context" },
|
||||
{
|
||||
type: "redacted_thinking",
|
||||
data: "x".repeat(500),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: "y".repeat(500),
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 150,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).not.toContain("redacted_thinking");
|
||||
expect(serialized).not.toContain("image/png");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.liveTailHandling).toBe("included_degraded");
|
||||
});
|
||||
|
||||
it("keeps unsafe blocks when input is already under budget", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look at this" },
|
||||
{
|
||||
type: "image",
|
||||
data: "small-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.actions).toEqual([]);
|
||||
expect(result.liveTailHandling).toBe("included_verbatim");
|
||||
expect(JSON.stringify(result.messages)).toContain("small-image");
|
||||
});
|
||||
|
||||
it("preserves unsafe blocks in the latest typed user message", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
data: "live-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 120,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result.messages)).toContain("live-image");
|
||||
expect(result.actions).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "dropped_block" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("protects latest typed user after thinking-only messages are pruned", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "discard me" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
data: "live-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("live-image");
|
||||
expect(serialized).not.toContain("discard me");
|
||||
});
|
||||
|
||||
it("keeps tool-use and tool-result pairs coherent when dropping history", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "original task" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "read_files",
|
||||
input: { file_paths: ["/tmp/a.ts"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read_files",
|
||||
content: "x".repeat(1000),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 140,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).not.toContain("tool_1");
|
||||
expect(serialized).toContain("latest task");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "tool_pair_boundary" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("records budget action paths against original message indexes", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "x", mediaType: "image/png" }],
|
||||
},
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "assistant", content: "old answer " + "y".repeat(500) },
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 80,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
path: expect.objectContaining({ messageIndex: 1 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
path: expect.objectContaining({ messageIndex: 2 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects the latest typed user message when tool results follow it", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "old task" },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_1", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read",
|
||||
content: "result",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findLatestTypedUserMessageIndex(messages)).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the latest typed prompt under pressure", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read",
|
||||
content: "result " + "y".repeat(500),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 120,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result.messages)).toContain("latest typed prompt");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "protected_live_tail" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not preserve later text or file blocks after tool-result budget is exhausted", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_live", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_live",
|
||||
name: "read",
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(200) },
|
||||
{ type: "file", path: "/tmp/huge.txt", content: "b".repeat(1_000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 260,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("latest typed prompt");
|
||||
expect(serialized).not.toContain("b".repeat(100));
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "truncated_text",
|
||||
reason: "over_budget",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops thinking blocks instead of mutating provider-native reasoning", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(1_000) },
|
||||
{ type: "thinking", thinking: "b".repeat(1_000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 190,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const assistant = result.messages.find(
|
||||
(message) => message.role === "assistant",
|
||||
);
|
||||
expect(JSON.stringify(assistant)).not.toContain("b".repeat(100));
|
||||
expect(JSON.stringify(assistant)).not.toContain("\"thinking\"");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops nested unsafe tool-result blocks outside the protected tail", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_old",
|
||||
name: "read",
|
||||
content: [
|
||||
{ type: "text", text: "old output" },
|
||||
{
|
||||
type: "image",
|
||||
data: "old-image-data",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("old output");
|
||||
expect(serialized).not.toContain("old-image-data");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
import type {
|
||||
ContentBlock,
|
||||
MessageWithMetadata,
|
||||
ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
BudgetAction,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
BudgetPolicyIntent,
|
||||
} from "./types";
|
||||
|
||||
type EstimateMessageTokens = (message: MessageWithMetadata) => number;
|
||||
|
||||
interface ProjectionPolicy {
|
||||
protectLatestTypedUser: boolean;
|
||||
protectLiveTailFromDrop: boolean;
|
||||
dropUnsafeOutsideLiveTail: boolean;
|
||||
dropThinkingBlocks: boolean;
|
||||
}
|
||||
|
||||
function resolveProjectionPolicy(
|
||||
intent: BudgetPolicyIntent,
|
||||
): ProjectionPolicy {
|
||||
switch (intent) {
|
||||
case "agentic_summary":
|
||||
case "basic_compaction_projection":
|
||||
return {
|
||||
protectLatestTypedUser: true,
|
||||
protectLiveTailFromDrop: true,
|
||||
dropUnsafeOutsideLiveTail: true,
|
||||
dropThinkingBlocks: true,
|
||||
};
|
||||
case "normal_provider_request":
|
||||
return {
|
||||
protectLatestTypedUser: true,
|
||||
protectLiveTailFromDrop: true,
|
||||
dropUnsafeOutsideLiveTail: false,
|
||||
dropThinkingBlocks: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function cloneMessages(messages: MessageWithMetadata[]): MessageWithMetadata[] {
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
content: Array.isArray(message.content)
|
||||
? message.content.map((block) => ({ ...block }) as ContentBlock)
|
||||
: message.content,
|
||||
...(message.metadata ? { metadata: { ...message.metadata } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function safeJsonSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
} catch {
|
||||
return String(value).length;
|
||||
}
|
||||
}
|
||||
|
||||
function totalTokens(
|
||||
messages: MessageWithMetadata[],
|
||||
estimateMessageTokens: EstimateMessageTokens,
|
||||
): number {
|
||||
return messages.reduce(
|
||||
(total, message) => total + estimateMessageTokens(message),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function isToolResultOnlyUserMessage(message: MessageWithMetadata): boolean {
|
||||
return (
|
||||
message.role === "user" &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.length > 0 &&
|
||||
message.content.every((block) => block.type === "tool_result")
|
||||
);
|
||||
}
|
||||
|
||||
export function findLatestTypedUserMessageIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collectToolIds(message: MessageWithMetadata): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!Array.isArray(message.content)) {
|
||||
return ids;
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use") {
|
||||
ids.add(block.id);
|
||||
} else if (block.type === "tool_result") {
|
||||
ids.add(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function buildToolPairIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): Map<string, Set<number>> {
|
||||
const index = new Map<string, Set<number>>();
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
for (const id of collectToolIds(messages[messageIndex])) {
|
||||
const existing = index.get(id);
|
||||
if (existing) {
|
||||
existing.add(messageIndex);
|
||||
} else {
|
||||
index.set(id, new Set([messageIndex]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function collectMessageClosure(
|
||||
messages: MessageWithMetadata[],
|
||||
startIndex: number,
|
||||
): Set<number> {
|
||||
const pairIndex = buildToolPairIndex(messages);
|
||||
const removal = new Set<number>();
|
||||
const queue = [startIndex];
|
||||
while (queue.length > 0) {
|
||||
const index = queue.shift();
|
||||
if (index === undefined || removal.has(index)) {
|
||||
continue;
|
||||
}
|
||||
removal.add(index);
|
||||
for (const id of collectToolIds(messages[index])) {
|
||||
for (const linked of pairIndex.get(id) ?? []) {
|
||||
if (!removal.has(linked)) {
|
||||
queue.push(linked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removal;
|
||||
}
|
||||
|
||||
function isUnsafeBlock(block: ContentBlock): boolean {
|
||||
return block.type === "image" || block.type === "redacted_thinking";
|
||||
}
|
||||
|
||||
function isNestedUnsafeToolResultBlock(
|
||||
block: Extract<ToolResultContent["content"], unknown[]>[number],
|
||||
): boolean {
|
||||
return block.type === "image";
|
||||
}
|
||||
|
||||
function shouldDropWholeBlock(
|
||||
block: ContentBlock,
|
||||
policy: ProjectionPolicy,
|
||||
isProtected: boolean,
|
||||
): boolean {
|
||||
if (policy.dropThinkingBlocks && block.type === "thinking") {
|
||||
return true;
|
||||
}
|
||||
return policy.dropUnsafeOutsideLiveTail && !isProtected && isUnsafeBlock(block);
|
||||
}
|
||||
|
||||
function pruneEmptyMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
|
||||
const next: MessageWithMetadata[] = [];
|
||||
const nextOriginalIndexes: number[] = [];
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
if (Array.isArray(message.content) && message.content.length === 0) {
|
||||
actions.push({
|
||||
kind: "dropped_message",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "over_budget",
|
||||
originalSize: safeJsonSize(message),
|
||||
finalSize: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push(message);
|
||||
nextOriginalIndexes.push(originalIndexes[index]);
|
||||
}
|
||||
return { messages: next, originalIndexes: nextOriginalIndexes };
|
||||
}
|
||||
|
||||
function dropUnsafeBlocks(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
protectedStartIndex: number,
|
||||
policy: ProjectionPolicy,
|
||||
): MessageWithMetadata[] {
|
||||
return messages.map((message, messageIndex) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
let changed = false;
|
||||
const protectedBlock =
|
||||
protectedStartIndex >= 0 && messageIndex >= protectedStartIndex;
|
||||
const content = message.content.flatMap((block, blockIndex) => {
|
||||
if (shouldDropWholeBlock(block, policy, protectedBlock)) {
|
||||
changed = true;
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: 0,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
if (block.type === "tool_result" && Array.isArray(block.content)) {
|
||||
const nestedContent = block.content.filter((nestedBlock) => {
|
||||
if (
|
||||
policy.dropUnsafeOutsideLiveTail &&
|
||||
!protectedBlock &&
|
||||
isNestedUnsafeToolResultBlock(nestedBlock)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (nestedContent.length !== block.content.length) {
|
||||
changed = true;
|
||||
const nextBlock = { ...block, content: nestedContent };
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: safeJsonSize(nextBlock),
|
||||
});
|
||||
return [nextBlock];
|
||||
}
|
||||
}
|
||||
if (!isUnsafeBlock(block)) {
|
||||
return [block];
|
||||
}
|
||||
if (protectedBlock) {
|
||||
return [block];
|
||||
}
|
||||
return [block];
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
});
|
||||
}
|
||||
|
||||
function dropThinkingBlocks(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
): MessageWithMetadata[] {
|
||||
return messages.map((message, messageIndex) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
let changed = false;
|
||||
const content = message.content.filter((block, blockIndex) => {
|
||||
if (block.type !== "thinking") {
|
||||
return true;
|
||||
}
|
||||
changed = true;
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: 0,
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function truncateText(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
if (maxChars <= 16) {
|
||||
return text.slice(0, Math.max(1, maxChars));
|
||||
}
|
||||
const estimateMarker = `\n...[truncated ${text.length - maxChars} chars]`;
|
||||
const keep = Math.max(1, maxChars - estimateMarker.length);
|
||||
const marker = `\n...[truncated ${text.length - keep} chars]`;
|
||||
return `${text.slice(0, keep)}${marker}`;
|
||||
}
|
||||
|
||||
function truncateToolResultContent(
|
||||
content: ToolResultContent["content"],
|
||||
maxChars: number,
|
||||
): ToolResultContent["content"] {
|
||||
if (typeof content === "string") {
|
||||
return truncateText(content, maxChars);
|
||||
}
|
||||
let remaining = maxChars;
|
||||
return content.map((block) => {
|
||||
if (remaining <= 0) {
|
||||
if (block.type === "text") {
|
||||
return { ...block, text: "" };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return { ...block, content: "" };
|
||||
}
|
||||
return block;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
const text = truncateText(block.text, remaining);
|
||||
remaining -= text.length;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
const content = truncateText(block.content, remaining);
|
||||
remaining -= content.length;
|
||||
return { ...block, content };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
||||
function toolResultTextLength(content: ToolResultContent["content"]): number {
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
return content.reduce((total, block) => {
|
||||
if (block.type === "text") {
|
||||
return total + block.text.length;
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return total + block.content.length;
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function truncateMessageText(
|
||||
message: MessageWithMetadata,
|
||||
maxChars: number,
|
||||
): MessageWithMetadata {
|
||||
if (typeof message.content === "string") {
|
||||
return { ...message, content: truncateText(message.content, maxChars) };
|
||||
}
|
||||
let remaining = maxChars;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((block) => {
|
||||
if (remaining <= 0) {
|
||||
if (block.type === "text") {
|
||||
return { ...block, text: "" };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return { ...block, content: "" };
|
||||
}
|
||||
return block;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
const text = truncateText(block.text, remaining);
|
||||
remaining -= text.length;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
const content = truncateText(block.content, remaining);
|
||||
remaining -= content.length;
|
||||
return { ...block, content };
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
const content = truncateToolResultContent(block.content, remaining);
|
||||
remaining -= toolResultTextLength(content);
|
||||
return { ...block, content };
|
||||
}
|
||||
return block;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function hasTruncatableText(message: MessageWithMetadata): boolean {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.length > 0;
|
||||
}
|
||||
return message.content.some(
|
||||
(block) =>
|
||||
block.type === "text" ||
|
||||
block.type === "file" ||
|
||||
block.type === "tool_result",
|
||||
);
|
||||
}
|
||||
|
||||
function removeMessagesAt(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
removal: Set<number>,
|
||||
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
|
||||
return {
|
||||
messages: messages.filter((_, index) => !removal.has(index)),
|
||||
originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)),
|
||||
};
|
||||
}
|
||||
|
||||
function closureTouchesProtectedTail(
|
||||
closure: Set<number>,
|
||||
protectedStartIndex: number,
|
||||
): boolean {
|
||||
if (protectedStartIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
for (const removalIndex of closure) {
|
||||
if (removalIndex >= protectedStartIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildBudgetProjection(
|
||||
options: BudgetProjectionOptions,
|
||||
): BudgetProjectionResult {
|
||||
const actions: BudgetAction[] = [];
|
||||
const warnings: BudgetProjectionWarning[] = [];
|
||||
const policy = resolveProjectionPolicy(options.policyIntent);
|
||||
if (options.targetTokens <= 0) {
|
||||
return {
|
||||
status: "failed",
|
||||
messages: cloneMessages(options.messages),
|
||||
actions,
|
||||
liveTailHandling: "preserved_out_of_band",
|
||||
estimatedTokens: totalTokens(
|
||||
options.messages,
|
||||
options.estimateMessageTokens,
|
||||
),
|
||||
warnings: [
|
||||
{
|
||||
code: "budget_impossible",
|
||||
message: "Target budget must be greater than zero.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let messages = cloneMessages(options.messages);
|
||||
let originalIndexes = messages.map((_, index) => index);
|
||||
if (policy.dropThinkingBlocks) {
|
||||
const prunedThinking = pruneEmptyMessages(
|
||||
dropThinkingBlocks(messages, originalIndexes, actions),
|
||||
originalIndexes,
|
||||
actions,
|
||||
);
|
||||
messages = prunedThinking.messages;
|
||||
originalIndexes = prunedThinking.originalIndexes;
|
||||
}
|
||||
const protectedStartIndex = policy.protectLatestTypedUser
|
||||
? findLatestTypedUserMessageIndex(messages)
|
||||
: -1;
|
||||
if (policy.dropUnsafeOutsideLiveTail) {
|
||||
const prunedUnsafe = pruneEmptyMessages(
|
||||
dropUnsafeBlocks(
|
||||
messages,
|
||||
originalIndexes,
|
||||
actions,
|
||||
protectedStartIndex,
|
||||
policy,
|
||||
),
|
||||
originalIndexes,
|
||||
actions,
|
||||
);
|
||||
messages = prunedUnsafe.messages;
|
||||
originalIndexes = prunedUnsafe.originalIndexes;
|
||||
}
|
||||
let estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
if (estimatedTokens <= options.targetTokens) {
|
||||
return {
|
||||
status: "ok",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling:
|
||||
actions.length > 0 ? "included_degraded" : "included_verbatim",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
for (
|
||||
let index = messages.length - 1;
|
||||
index >= 0 && estimatedTokens > options.targetTokens;
|
||||
index -= 1
|
||||
) {
|
||||
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
|
||||
if (index === latestTypedUserIndex) {
|
||||
continue;
|
||||
}
|
||||
if (!hasTruncatableText(messages[index])) {
|
||||
continue;
|
||||
}
|
||||
const originalSize = safeJsonSize(messages[index]);
|
||||
const charsPerToken = Math.max(
|
||||
1,
|
||||
originalSize /
|
||||
Math.max(1, options.estimateMessageTokens(messages[index])),
|
||||
);
|
||||
const targetChars = Math.max(
|
||||
16,
|
||||
Math.floor(
|
||||
(options.targetTokens * charsPerToken) /
|
||||
Math.max(1, messages.length),
|
||||
),
|
||||
);
|
||||
messages[index] = truncateMessageText(messages[index], targetChars);
|
||||
actions.push({
|
||||
kind: "truncated_text",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "over_budget",
|
||||
originalSize,
|
||||
finalSize: safeJsonSize(messages[index]),
|
||||
});
|
||||
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
}
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < messages.length && estimatedTokens > options.targetTokens;
|
||||
) {
|
||||
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
|
||||
const protectedStartIndex = policy.protectLiveTailFromDrop
|
||||
? latestTypedUserIndex
|
||||
: -1;
|
||||
if (index === latestTypedUserIndex) {
|
||||
actions.push({
|
||||
kind: "preserved",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "protected_live_tail",
|
||||
originalSize: safeJsonSize(messages[index]),
|
||||
finalSize: safeJsonSize(messages[index]),
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const closure = collectMessageClosure(messages, index);
|
||||
if (closureTouchesProtectedTail(closure, protectedStartIndex)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
for (const removalIndex of closure) {
|
||||
actions.push({
|
||||
kind: "dropped_message",
|
||||
path: { messageIndex: originalIndexes[removalIndex] },
|
||||
reason:
|
||||
closure.size > 1 || collectToolIds(messages[removalIndex]).size > 0
|
||||
? "tool_pair_boundary"
|
||||
: "over_budget",
|
||||
originalSize: safeJsonSize(messages[removalIndex]),
|
||||
finalSize: 0,
|
||||
});
|
||||
}
|
||||
const removed = removeMessagesAt(messages, originalIndexes, closure);
|
||||
messages = removed.messages;
|
||||
originalIndexes = removed.originalIndexes;
|
||||
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
}
|
||||
|
||||
if (estimatedTokens > options.targetTokens) {
|
||||
warnings.push({
|
||||
code: "budget_unachievable_with_protections",
|
||||
message:
|
||||
"Projection could not reach budget without violating protected content.",
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling: "included_degraded",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling:
|
||||
actions.length > 0 ? "included_degraded" : "included_verbatim",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ContentBlock, MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
export type BudgetPolicyIntent =
|
||||
| "agentic_summary"
|
||||
| "basic_compaction_projection"
|
||||
| "normal_provider_request";
|
||||
|
||||
export type BudgetActionKind =
|
||||
| "truncated_text"
|
||||
| "dropped_block"
|
||||
| "dropped_message"
|
||||
| "preserved";
|
||||
|
||||
export type BudgetActionReason =
|
||||
| "over_budget"
|
||||
| "unsafe_to_truncate"
|
||||
| "tool_pair_boundary"
|
||||
| "protected_live_tail";
|
||||
|
||||
export type LiveTailHandling =
|
||||
| "included_verbatim"
|
||||
| "included_degraded"
|
||||
| "summarized_as_context"
|
||||
| "omitted_with_warning"
|
||||
| "preserved_out_of_band";
|
||||
|
||||
export type BlockBudgetClass =
|
||||
| "text"
|
||||
| "thinking"
|
||||
| "tool_use"
|
||||
| "tool_result"
|
||||
| "unsafe_binary"
|
||||
| "unsafe_encrypted"
|
||||
| "opaque";
|
||||
|
||||
export interface BudgetPath {
|
||||
messageIndex: number;
|
||||
blockIndex?: number;
|
||||
}
|
||||
|
||||
interface BaseBudgetAction {
|
||||
path: BudgetPath;
|
||||
originalSize: number;
|
||||
finalSize: number;
|
||||
}
|
||||
|
||||
export type BudgetMutationAction =
|
||||
| (BaseBudgetAction & {
|
||||
kind: "truncated_text";
|
||||
reason: Extract<BudgetActionReason, "over_budget">;
|
||||
})
|
||||
| (BaseBudgetAction & {
|
||||
kind: "dropped_block" | "dropped_message";
|
||||
reason: Exclude<BudgetActionReason, "protected_live_tail">;
|
||||
});
|
||||
|
||||
export interface BudgetPreservedAction extends BaseBudgetAction {
|
||||
kind: "preserved";
|
||||
reason: Extract<
|
||||
BudgetActionReason,
|
||||
"protected_live_tail" | "tool_pair_boundary"
|
||||
>;
|
||||
}
|
||||
|
||||
export type BudgetAction = BudgetMutationAction | BudgetPreservedAction;
|
||||
|
||||
export type BudgetProjectionWarningCode =
|
||||
| "budget_impossible"
|
||||
| "budget_unachievable_with_protections";
|
||||
|
||||
export interface BudgetProjectionWarning {
|
||||
code: BudgetProjectionWarningCode;
|
||||
message: string;
|
||||
path?: BudgetPath;
|
||||
}
|
||||
|
||||
export interface BudgetProjectionOptions {
|
||||
messages: MessageWithMetadata[];
|
||||
targetTokens: number;
|
||||
policyIntent: BudgetPolicyIntent;
|
||||
estimateMessageTokens: (message: MessageWithMetadata) => number;
|
||||
}
|
||||
|
||||
export interface BudgetProjectionResult {
|
||||
status: "ok" | "failed";
|
||||
messages: MessageWithMetadata[];
|
||||
actions: BudgetAction[];
|
||||
liveTailHandling: LiveTailHandling;
|
||||
estimatedTokens: number;
|
||||
warnings: BudgetProjectionWarning[];
|
||||
}
|
||||
|
||||
export interface ContentBlockBudgetClassification {
|
||||
block: ContentBlock;
|
||||
budgetClass: BlockBudgetClass;
|
||||
canStringTruncate: boolean;
|
||||
canDropWholeBlock: boolean;
|
||||
}
|
||||
@@ -480,6 +480,7 @@ export function resolveSummarizerConfig(options: {
|
||||
apiKey: summarizer.apiKey ?? baseProviderConfig?.apiKey,
|
||||
baseUrl: summarizer.baseUrl ?? baseProviderConfig?.baseUrl,
|
||||
headers: summarizer.headers ?? baseProviderConfig?.headers,
|
||||
modelInfo: summarizer.modelInfo ?? baseProviderConfig?.modelInfo,
|
||||
knownModels: summarizer.knownModels ?? baseProviderConfig?.knownModels,
|
||||
maxOutputTokens:
|
||||
summarizer.maxOutputTokens ?? DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreCompactionContext } from "../../types/config";
|
||||
import { buildAgenticSummaryInputBudget } from "./agentic-compaction";
|
||||
import { runBasicCompaction } from "./basic-compaction";
|
||||
import { createContextCompactionPrepareTurn } from "./compaction";
|
||||
import {
|
||||
createTokenEstimator,
|
||||
estimateTokens,
|
||||
resolveSummarizerConfig,
|
||||
serializeMessage,
|
||||
TOOL_RESULT_CHAR_LIMIT,
|
||||
@@ -359,13 +361,28 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
const compacted = runForcedBasicCompaction(messages, 1);
|
||||
|
||||
expect(compacted).toEqual([
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "user", content: "Read the latest file" },
|
||||
assistantToolUseMessage("tool-a"),
|
||||
toolResultMessage("tool-a", "latest result"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("budgets the complete basic compaction output including the latest turn", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "original task" },
|
||||
{ role: "assistant", content: "old assistant " + "x".repeat(10_000) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
assistantToolUseMessage("tool-live"),
|
||||
toolResultMessage("tool-live", "live result " + "y".repeat(10_000)),
|
||||
];
|
||||
|
||||
const compacted = runForcedBasicCompaction(messages, 700);
|
||||
|
||||
expect(totalJsonTokens(compacted)).toBeLessThanOrEqual(700);
|
||||
expect(JSON.stringify(compacted)).toContain("latest typed prompt");
|
||||
expectNoOrphanedToolPairs(compacted);
|
||||
});
|
||||
|
||||
it("does not compact a single typed user message", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "Only current request" },
|
||||
@@ -396,6 +413,23 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(anthropicConfig.maxOutputTokens).toBe(1_024);
|
||||
});
|
||||
|
||||
it("preserves summarizer modelInfo without a nested providerConfig", () => {
|
||||
const resolved = resolveSummarizerConfig({
|
||||
activeProviderConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
modelInfo: { id: "primary-model", maxInputTokens: 100_000 },
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
summarizer: {
|
||||
providerId: "openai",
|
||||
modelId: "small-summary",
|
||||
modelInfo: { id: "small-summary", maxInputTokens: 600 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.modelInfo?.maxInputTokens).toBe(600);
|
||||
});
|
||||
|
||||
it("summarizes older messages and keeps recent messages", async () => {
|
||||
const emitStatusNotice = vi.fn();
|
||||
createHandlerMock.mockReturnValue({
|
||||
@@ -648,6 +682,43 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(summarizerPrompt.length).toBeLessThan(longToolOutput.length);
|
||||
});
|
||||
|
||||
it("budgets agentic summary input before serialization", () => {
|
||||
const result = buildAgenticSummaryInputBudget({
|
||||
messages: [
|
||||
{ role: "user", content: "Run a large command" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-large",
|
||||
name: "execute_command",
|
||||
input: { command: "print-large-output" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-large",
|
||||
name: "execute_command",
|
||||
content: "x".repeat(50_000),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Latest typed prompt" },
|
||||
],
|
||||
targetTokens: 400,
|
||||
estimateMessageTokens: estimateJsonTokens,
|
||||
});
|
||||
|
||||
expect(result.estimatedTokens).toBeLessThanOrEqual(400);
|
||||
expect(JSON.stringify(result.messages)).toContain("Latest typed prompt");
|
||||
expect(result.actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never lands the agentic cut in the middle of a tool pair", async () => {
|
||||
// Repro for the "No tool call found for function call output" provider
|
||||
// error: findCutIndex used to walk back by token budget and could land
|
||||
@@ -822,6 +893,79 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("budgets agentic summary input against the configured summarizer context window", async () => {
|
||||
let summaryRequest = "";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn((_system: string, messages: LlmsProviders.Message[]) => {
|
||||
summaryRequest = String(messages[0]?.content ?? "");
|
||||
return streamChunks([
|
||||
{ type: "text", id: "summary-small", text: "## Goal\nSummarized" },
|
||||
{ type: "done", id: "summary-small", success: true },
|
||||
]);
|
||||
}),
|
||||
});
|
||||
|
||||
const summarizerLimit = 600;
|
||||
const oversizedAssistant = "assistant details ".repeat(5_000);
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
providerConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
modelInfo: { id: "primary-model", maxInputTokens: 10_000 },
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
preserveRecentTokens: 1,
|
||||
reserveTokens: 5,
|
||||
summarizer: {
|
||||
providerId: "openai",
|
||||
modelId: "small-summary",
|
||||
modelInfo: {
|
||||
id: "small-summary",
|
||||
maxInputTokens: summarizerLimit,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages: [
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "assistant", content: oversizedAssistant },
|
||||
{ role: "user", content: "Latest turn" },
|
||||
{ role: "assistant", content: "Latest answer" },
|
||||
],
|
||||
apiMessages: [
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "assistant", content: oversizedAssistant },
|
||||
{ role: "user", content: "Latest turn" },
|
||||
{ role: "assistant", content: "Latest answer" },
|
||||
],
|
||||
model: {
|
||||
id: "primary-model",
|
||||
provider: "anthropic",
|
||||
info: { id: "primary-model", maxInputTokens: 10_000 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(estimateTokens(summaryRequest.length)).toBeLessThanOrEqual(
|
||||
summarizerLimit,
|
||||
);
|
||||
expect(summaryRequest).not.toContain(oversizedAssistant);
|
||||
});
|
||||
|
||||
it("uses basic compaction without calling the summarizer", async () => {
|
||||
const emitStatusNotice = vi.fn();
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
@@ -1311,7 +1455,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result?.messages.length).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it("preserves user image blocks during basic compaction sanitization", () => {
|
||||
it("drops old user image blocks during basic compaction sanitization", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
@@ -1347,7 +1491,6 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result?.messages).toBeDefined();
|
||||
expect(result?.messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Older user turn" },
|
||||
{ type: "image", data: "abc", mediaType: "image/png" },
|
||||
]);
|
||||
expect(result?.messages.at(-1)).toEqual({
|
||||
role: "user",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import {
|
||||
captureCompactionBudgetEmergency,
|
||||
captureCompactionExecuted,
|
||||
captureCompactionSkipped,
|
||||
type TelemetryCompactionStrategy,
|
||||
} from "../../services/telemetry/core-events";
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "../../session/models/session-compaction";
|
||||
import type {
|
||||
CoreCompactionConfig,
|
||||
CoreCompactionContext,
|
||||
@@ -43,6 +49,10 @@ export interface ContextPipelinePrepareTurnResult {
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export type ContextPipelinePrepareTurn = (
|
||||
context: ContextPipelinePrepareTurnInput,
|
||||
) => Promise<ContextPipelinePrepareTurnResult | undefined>;
|
||||
|
||||
type EstimateMessageTokens = ReturnType<typeof createTokenEstimator>;
|
||||
|
||||
type BuiltinCompactionStrategyOptions = {
|
||||
@@ -408,6 +418,31 @@ export function createContextCompactionPrepareTurn(
|
||||
modelId: config.modelId,
|
||||
...telemetryIdentity,
|
||||
});
|
||||
if (
|
||||
result.budget &&
|
||||
(result.budget.actionCount > 0 || result.budget.warningCount > 0)
|
||||
) {
|
||||
captureCompactionBudgetEmergency(config.telemetry, {
|
||||
ulid: telemetryUlid,
|
||||
strategy: telemetryStrategy,
|
||||
mode,
|
||||
policyIntent: result.budget.policyIntent,
|
||||
actionCount: result.budget.actionCount,
|
||||
warningCount: result.budget.warningCount,
|
||||
liveTailHandling: result.budget.liveTailHandling,
|
||||
provider: config.providerId,
|
||||
modelId: config.modelId,
|
||||
...telemetryIdentity,
|
||||
});
|
||||
context.emitStatusNotice?.("compaction-budget-adjusted", {
|
||||
kind: "compaction_budget_emergency",
|
||||
reason: "compaction_budget_emergency",
|
||||
iteration: context.iteration,
|
||||
policyIntent: result.budget.policyIntent,
|
||||
actionCount: result.budget.actionCount,
|
||||
warningCount: result.budget.warningCount,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
captureCompactionSkipped(config.telemetry, {
|
||||
ulid: telemetryUlid,
|
||||
@@ -428,3 +463,67 @@ export function createContextCompactionPrepareTurn(
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompactionStateAwarePrepareTurn(input: {
|
||||
compact?: ContextPipelinePrepareTurn;
|
||||
getState?: () => SessionCompactionState | undefined;
|
||||
saveState?: (state: SessionCompactionState) => void | Promise<void>;
|
||||
clearState?: () => void | Promise<void>;
|
||||
}): ContextPipelinePrepareTurn {
|
||||
return async (context) => {
|
||||
const existingState = input.getState?.();
|
||||
const projectedMessages = existingState
|
||||
? projectSessionCompactionState(existingState, context.messages)
|
||||
: undefined;
|
||||
if (existingState && projectedMessages) {
|
||||
// Re-compaction intentionally starts from the compacted projection plus
|
||||
// canonical tail. This keeps automatic turns bounded without rebuilding a
|
||||
// full-transcript summary every turn; manual `/compact` is the path for a
|
||||
// fresh summary from canonical history.
|
||||
const result = input.compact
|
||||
? await input.compact({
|
||||
...context,
|
||||
messages: projectedMessages,
|
||||
apiMessages: projectedMessages,
|
||||
})
|
||||
: undefined;
|
||||
if (result?.messages) {
|
||||
const systemPrompt = result.systemPrompt ?? existingState.system_prompt;
|
||||
const nextState = createSessionCompactionState({
|
||||
sourceMessages: context.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: context.conversationId,
|
||||
systemPrompt,
|
||||
});
|
||||
await input.saveState?.(nextState);
|
||||
return {
|
||||
...result,
|
||||
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: projectedMessages,
|
||||
...(result?.systemPrompt !== undefined
|
||||
? { systemPrompt: result.systemPrompt }
|
||||
: existingState.system_prompt !== undefined
|
||||
? { systemPrompt: existingState.system_prompt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (existingState) {
|
||||
await input.clearState?.();
|
||||
}
|
||||
|
||||
const result = input.compact ? await input.compact(context) : undefined;
|
||||
if (result?.messages) {
|
||||
const nextState = createSessionCompactionState({
|
||||
sourceMessages: context.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: context.conversationId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
});
|
||||
await input.saveState?.(nextState);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ import type {
|
||||
} from "../../runtime/host/runtime-host";
|
||||
import { isSessionNotFoundError } from "../../runtime/host/runtime-host";
|
||||
import { RuntimeHostEventBus } from "../../runtime/host/runtime-host-support";
|
||||
import {
|
||||
parseSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "../../session/models/session-compaction";
|
||||
import {
|
||||
type SessionManifest,
|
||||
SessionManifestSchema,
|
||||
@@ -821,6 +825,9 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
input.toolPolicies as Record<string, unknown> | undefined,
|
||||
),
|
||||
initialMessages: input.initialMessages,
|
||||
...(input.initialCompactionState
|
||||
? { initialCompactionState: input.initialCompactionState }
|
||||
: {}),
|
||||
});
|
||||
this.registerPlannedSession(
|
||||
plannedSessionId,
|
||||
@@ -958,6 +965,12 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
),
|
||||
...(startConfig.initialCompactionState
|
||||
? {
|
||||
initialCompactionState:
|
||||
startConfig.initialCompactionState,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -1277,6 +1290,38 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
return { updated: reply.ok };
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return { updated: false };
|
||||
const reply = await this.client.command(
|
||||
"session.compaction.update",
|
||||
{ sessionId: target, state },
|
||||
target,
|
||||
);
|
||||
return {
|
||||
updated: reply.ok && reply.payload?.updated === true,
|
||||
};
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return undefined;
|
||||
const reply = await this.client.command(
|
||||
"session.compaction.get",
|
||||
{ sessionId: target },
|
||||
target,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(hubReplyErrorMessage(reply, "session.compaction.get"));
|
||||
}
|
||||
return parseSessionCompactionState(reply.payload?.state);
|
||||
}
|
||||
|
||||
async readSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<import("@cline/llms").Message[]> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type StartSessionInput,
|
||||
type StartSessionResult,
|
||||
} from "../../runtime/host/runtime-host";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import { HubServerTransport } from "../server";
|
||||
import {
|
||||
@@ -797,6 +798,200 @@ describe("HubServerTransport boundaries", () => {
|
||||
expect(ctx.pendingCapabilityRequests.has("capreq-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let session metadata updates overwrite server-owned compaction owner", async () => {
|
||||
const updateSession = vi.fn().mockResolvedValue({ updated: true });
|
||||
const transport = createTransport({
|
||||
sessionHost: {
|
||||
updateSession,
|
||||
},
|
||||
});
|
||||
|
||||
await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-update",
|
||||
command: "session.update",
|
||||
clientId: "attacker-client",
|
||||
sessionId: "session-1",
|
||||
payload: {
|
||||
metadata: {
|
||||
hubCapabilityOwnerClientId: "attacker-client",
|
||||
title: "safe title",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateSession).toHaveBeenCalledWith("session-1", {
|
||||
metadata: { title: "safe title" },
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes compaction sidecar access from server session state, not mutable metadata", async () => {
|
||||
const readSessionCompactionState = vi.fn();
|
||||
const transport = createTransport({
|
||||
sessionHost: {
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
status: "completed",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
workspaceRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
metadata: { hubCapabilityOwnerClientId: "attacker-client" },
|
||||
}),
|
||||
readSessionCompactionState,
|
||||
},
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact",
|
||||
command: "session.compaction.get",
|
||||
clientId: "attacker-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_wrong_client" },
|
||||
});
|
||||
expect(readSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns compaction sidecar state to the server-owned session client", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
|
||||
const transport = createTransport({
|
||||
sessionHost: { readSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-get",
|
||||
command: "session.compaction.get",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: true,
|
||||
payload: { sessionId: "session-1", state },
|
||||
});
|
||||
expect(readSessionCompactionState).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
it("rejects invalid compaction sidecar updates before calling the session host", async () => {
|
||||
const updateSessionCompactionState = vi.fn();
|
||||
const transport = createTransport({
|
||||
sessionHost: { updateSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-update-invalid",
|
||||
command: "session.compaction.update",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state: { version: 1, messages: "bad" } },
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "invalid_compaction_state" },
|
||||
});
|
||||
expect(updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes session updates after successful compaction sidecar updates", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const updateSessionCompactionState = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true });
|
||||
const transport = createTransport({
|
||||
sessionHost: { updateSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
const events: HubEventEnvelope[] = [];
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
transport.subscribe("owner-client", (event) => events.push(event));
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-update",
|
||||
command: "session.compaction.update",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state },
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: true,
|
||||
payload: { updated: true },
|
||||
});
|
||||
expect(updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
state,
|
||||
);
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
event: "session.updated",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not publish session updates when compaction sidecar update is stale", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const updateSessionCompactionState = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: false });
|
||||
const transport = createTransport({
|
||||
sessionHost: { updateSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
const events: HubEventEnvelope[] = [];
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
transport.subscribe("owner-client", (event) => events.push(event));
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-stale",
|
||||
command: "session.compaction.update",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state },
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
payload: { updated: false },
|
||||
});
|
||||
expect(events).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ event: "session.updated" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels pending capability requests when a run is aborted", async () => {
|
||||
const abort = vi.fn().mockResolvedValue(undefined);
|
||||
const transport = createTransport({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "@cline/shared";
|
||||
import { createSessionId, parseRuntimeConfigExtensions } from "@cline/shared";
|
||||
import type { RuntimeSessionConfig } from "../../../runtime/host/runtime-host";
|
||||
import { parseSessionCompactionState } from "../../../session/models/session-compaction";
|
||||
import {
|
||||
SessionVersioningError,
|
||||
SessionVersioningService,
|
||||
@@ -38,10 +39,49 @@ function setCapabilityOwner(
|
||||
}
|
||||
|
||||
function getCapabilityOwnerClientId(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
ctx: HubTransportContext,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
const owner = metadata?.[CAPABILITY_OWNER_METADATA_KEY];
|
||||
return typeof owner === "string" && owner.trim() ? owner.trim() : 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.
|
||||
return ctx.sessionState.get(sessionId)?.createdByClientId;
|
||||
}
|
||||
|
||||
function stripServerOwnedSessionMetadata(
|
||||
metadata: Record<string, JsonValue | undefined> | undefined,
|
||||
): Record<string, JsonValue | undefined> | undefined {
|
||||
if (!metadata || !(CAPABILITY_OWNER_METADATA_KEY in metadata)) {
|
||||
return metadata;
|
||||
}
|
||||
const sanitized = { ...metadata };
|
||||
delete sanitized[CAPABILITY_OWNER_METADATA_KEY];
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function authorizeSessionCompactionAccess(input: {
|
||||
sessionId: string;
|
||||
ctx: HubTransportContext;
|
||||
clientId: string;
|
||||
envelope: HubCommandEnvelope;
|
||||
}): HubReplyEnvelope | undefined {
|
||||
const ownerClientId = getCapabilityOwnerClientId(input.ctx, input.sessionId);
|
||||
if (!ownerClientId) {
|
||||
return errorReply(
|
||||
input.envelope,
|
||||
"session_wrong_client",
|
||||
`Session ${input.sessionId} has no authorized owner`,
|
||||
);
|
||||
}
|
||||
if (ownerClientId !== input.clientId) {
|
||||
return errorReply(
|
||||
input.envelope,
|
||||
"session_wrong_client",
|
||||
`Session ${input.sessionId} is owned by ${ownerClientId}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleSessionCreate(
|
||||
@@ -77,6 +117,9 @@ export async function handleSessionCreate(
|
||||
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
|
||||
? (payload.runtimeOptions as Record<string, unknown>)
|
||||
: {};
|
||||
const initialCompactionState = parseSessionCompactionState(
|
||||
payload.initialCompactionState,
|
||||
);
|
||||
if (typeof sessionConfig?.mode === "string") {
|
||||
metadata.mode = sessionConfig.mode;
|
||||
} else if (typeof runtimeOptions.mode === "string") {
|
||||
@@ -124,9 +167,7 @@ export async function handleSessionCreate(
|
||||
cwd: typeof payload.cwd === "string" ? payload.cwd : undefined,
|
||||
contributionCount: clientContributions.length,
|
||||
});
|
||||
if (clientContributions.length > 0) {
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
}
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
const requestedSessionId =
|
||||
typeof sessionConfig?.sessionId === "string"
|
||||
? sessionConfig.sessionId.trim()
|
||||
@@ -175,6 +216,7 @@ export async function handleSessionCreate(
|
||||
initialMessages: Array.isArray(payload.initialMessages)
|
||||
? (payload.initialMessages as never[])
|
||||
: undefined,
|
||||
initialCompactionState,
|
||||
localRuntime: {
|
||||
modelCatalogDefaults: {
|
||||
loadLatestOnInit: true,
|
||||
@@ -352,6 +394,9 @@ export async function handleSessionRestore(
|
||||
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
|
||||
? (payload.runtimeOptions as Record<string, unknown>)
|
||||
: {};
|
||||
const initialCompactionState = parseSessionCompactionState(
|
||||
payload.initialCompactionState,
|
||||
);
|
||||
const metadata =
|
||||
payload.metadata && typeof payload.metadata === "object"
|
||||
? JSON.parse(JSON.stringify(payload.metadata))
|
||||
@@ -380,9 +425,7 @@ export async function handleSessionRestore(
|
||||
const clientContributions = parseHubClientContributions(
|
||||
runtimeOptions.clientContributions,
|
||||
);
|
||||
if (clientContributions.length > 0) {
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
}
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
const requestedSessionId =
|
||||
typeof sessionConfig?.sessionId === "string"
|
||||
? sessionConfig.sessionId.trim()
|
||||
@@ -439,6 +482,7 @@ export async function handleSessionRestore(
|
||||
restoredCheckpointRunCount: checkpointRunCount,
|
||||
},
|
||||
initialMessages: context.initialMessages,
|
||||
initialCompactionState,
|
||||
localRuntime: {
|
||||
modelCatalogDefaults: {
|
||||
loadLatestOnInit: true,
|
||||
@@ -618,13 +662,7 @@ export async function handleSessionDetach(
|
||||
);
|
||||
}
|
||||
const clientId = envelope.clientId?.trim() || "hub-client";
|
||||
const [existingSession] = await Promise.all([
|
||||
readHubSessionRecord(ctx, sessionId),
|
||||
]);
|
||||
const ownerClientId =
|
||||
getCapabilityOwnerClientId(
|
||||
existingSession?.metadata as Record<string, unknown> | undefined,
|
||||
) ?? clientId;
|
||||
const ownerClientId = getCapabilityOwnerClientId(ctx, sessionId) ?? clientId;
|
||||
const state = ctx.sessionState.get(sessionId);
|
||||
if (state) {
|
||||
state.participants.delete(clientId);
|
||||
@@ -702,6 +740,40 @@ export async function handleSessionMessages(
|
||||
return okReply(envelope, { sessionId, messages });
|
||||
}
|
||||
|
||||
export async function handleSessionCompactionGet(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
if (!sessionId) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_session_id",
|
||||
"session.compaction.get requires a session id",
|
||||
);
|
||||
}
|
||||
const session = await readHubSessionRecord(ctx, sessionId);
|
||||
if (!session) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"session_not_found",
|
||||
`Unknown session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
const clientId = envelope.clientId?.trim() || "hub-client";
|
||||
const unauthorized = authorizeSessionCompactionAccess({
|
||||
sessionId,
|
||||
ctx,
|
||||
clientId,
|
||||
envelope,
|
||||
});
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
const state = await ctx.sessionHost.readSessionCompactionState(sessionId);
|
||||
return okReply(envelope, { sessionId, state });
|
||||
}
|
||||
|
||||
export async function handleSessionList(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
@@ -722,7 +794,9 @@ export async function handleSessionUpdate(
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
const metadata = asPlainRecord(envelope.payload?.metadata);
|
||||
const metadata = stripServerOwnedSessionMetadata(
|
||||
asPlainRecord(envelope.payload?.metadata),
|
||||
);
|
||||
const updated = await ctx.sessionHost.updateSession(sessionId, { metadata });
|
||||
const [session, snapshot] = await Promise.all([
|
||||
readHubSessionRecord(ctx, sessionId),
|
||||
@@ -749,6 +823,82 @@ export async function handleSessionUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionCompactionUpdate(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
if (!sessionId) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_session_id",
|
||||
"session.compaction.update requires a session id",
|
||||
);
|
||||
}
|
||||
const clientId = envelope.clientId?.trim() || "hub-client";
|
||||
const session = await readHubSessionRecord(ctx, sessionId);
|
||||
if (!session) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"session_not_found",
|
||||
`Unknown session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
const unauthorized = authorizeSessionCompactionAccess({
|
||||
sessionId,
|
||||
ctx,
|
||||
clientId,
|
||||
envelope,
|
||||
});
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
const payload =
|
||||
envelope.payload && typeof envelope.payload === "object"
|
||||
? envelope.payload
|
||||
: {};
|
||||
const state = parseSessionCompactionState(payload.state);
|
||||
if (!state) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_compaction_state",
|
||||
"session.compaction.update requires a valid compaction state",
|
||||
);
|
||||
}
|
||||
const updated = await ctx.sessionHost.updateSessionCompactionState(
|
||||
sessionId,
|
||||
state,
|
||||
);
|
||||
const [updatedSession, snapshot] = updated.updated
|
||||
? await Promise.all([
|
||||
readHubSessionRecord(ctx, sessionId),
|
||||
readCoreSessionSnapshot(ctx, sessionId),
|
||||
])
|
||||
: [session, undefined];
|
||||
if (updated.updated) {
|
||||
ctx.publish(
|
||||
ctx.buildEvent(
|
||||
"session.updated",
|
||||
{
|
||||
session: updatedSession ?? session,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
},
|
||||
sessionId,
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: envelope.version,
|
||||
requestId: envelope.requestId,
|
||||
ok: updated.updated,
|
||||
payload: {
|
||||
updated: updated.updated,
|
||||
session: updatedSession ?? session,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionDelete(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
import { projectSessionEvent } from "./handlers/session-event-projector";
|
||||
import {
|
||||
handleSessionAttach,
|
||||
handleSessionCompactionGet,
|
||||
handleSessionCompactionUpdate,
|
||||
handleSessionCreate,
|
||||
handleSessionDelete,
|
||||
handleSessionDetach,
|
||||
@@ -360,10 +362,14 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
return await handleSessionGet(this.ctx, envelope);
|
||||
case "session.messages":
|
||||
return await handleSessionMessages(this.ctx, envelope);
|
||||
case "session.compaction.get":
|
||||
return await handleSessionCompactionGet(this.ctx, envelope);
|
||||
case "session.list":
|
||||
return await handleSessionList(this.ctx, envelope);
|
||||
case "session.update":
|
||||
return await handleSessionUpdate(this.ctx, envelope);
|
||||
case "session.compaction.update":
|
||||
return await handleSessionCompactionUpdate(this.ctx, envelope);
|
||||
case "session.pending_prompts":
|
||||
return await handleSessionPendingPrompts(this.ctx, envelope);
|
||||
case "session.update_pending_prompt":
|
||||
|
||||
@@ -641,7 +641,10 @@ export async function loadOpenTelemetryAdapter() {
|
||||
return import("./services/telemetry/index.js");
|
||||
}
|
||||
export { Agent, createAgentRuntime } from "@cline/agents";
|
||||
export { createContextCompactionPrepareTurn } from "./extensions/context/compaction";
|
||||
export {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
createContextCompactionPrepareTurn,
|
||||
} from "./extensions/context/compaction";
|
||||
export {
|
||||
ALL_DEFAULT_TOOL_NAMES,
|
||||
type AskQuestionExecutor,
|
||||
@@ -752,6 +755,12 @@ export {
|
||||
TelemetryService,
|
||||
type TelemetryServiceOptions,
|
||||
} from "./services/telemetry/TelemetryService";
|
||||
export {
|
||||
createSessionCompactionState,
|
||||
parseSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "./session/models/session-compaction";
|
||||
// Compatibility barrel (legacy imports).
|
||||
export type { RuntimeEnvironment } from "./types";
|
||||
export type { SessionStatus } from "./types/common";
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { setHomeDirIfUnset } from "@cline/shared/storage";
|
||||
import { isOAuthProvider } from "../../auth/provider-auth-registry";
|
||||
import { createContextCompactionPrepareTurn } from "../../extensions/context/compaction";
|
||||
import {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
createContextCompactionPrepareTurn,
|
||||
} from "../../extensions/context/compaction";
|
||||
import type { ToolExecutors } from "../../extensions/tools";
|
||||
import { DefaultToolNames } from "../../extensions/tools";
|
||||
import type { TeamEvent } from "../../extensions/tools/team";
|
||||
@@ -47,6 +50,10 @@ import {
|
||||
sumUsageTotals,
|
||||
} from "../../services/usage";
|
||||
import { enrichPromptWithMentions } from "../../services/workspace";
|
||||
import {
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "../../session/models/session-compaction";
|
||||
import {
|
||||
type SessionManifest,
|
||||
SessionManifestSchema,
|
||||
@@ -170,6 +177,19 @@ function maxAccumulatedUsage(
|
||||
};
|
||||
}
|
||||
|
||||
function isIncomingCompactionStateStale(
|
||||
incoming: SessionCompactionState,
|
||||
current: SessionCompactionState | undefined,
|
||||
): boolean {
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
if (incoming.source_message_count !== current.source_message_count) {
|
||||
return incoming.source_message_count < current.source_message_count;
|
||||
}
|
||||
return incoming.updated_at < current.updated_at;
|
||||
}
|
||||
|
||||
export interface LocalRuntimeHostOptions {
|
||||
distinctId?: string;
|
||||
sessionService: SessionBackend;
|
||||
@@ -318,6 +338,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
messages_path: messagesPath,
|
||||
});
|
||||
let resumedArtifacts: RootSessionArtifacts | undefined;
|
||||
let resumedCompactionState: SessionCompactionState | undefined;
|
||||
const isReadOnlyResumeStart =
|
||||
requestedSessionId.length > 0 &&
|
||||
initialMessages.length > 0 &&
|
||||
@@ -332,8 +353,14 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
resumedArtifacts = {
|
||||
manifestPath,
|
||||
messagesPath: existingManifest.messages_path || messagesPath,
|
||||
compactionPath: existingManifest.compaction_path,
|
||||
manifest: existingManifest,
|
||||
};
|
||||
resumedCompactionState =
|
||||
await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const initialAggregateUsage = await this.seedAggregateUsageFromArtifacts({
|
||||
@@ -420,6 +447,80 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
|
||||
const tools = [...runtime.tools, ...(configWithProvider.extraTools ?? [])];
|
||||
const extensions = runtime.extensions ?? bootstrap.extensions;
|
||||
const explicitInitialCompactionState = startInput.initialCompactionState;
|
||||
let activeSessionRef: ActiveSession | undefined;
|
||||
const compact = createContextCompactionPrepareTurn(configWithProvider);
|
||||
const initialCompactionState =
|
||||
explicitInitialCompactionState ??
|
||||
(compact ? resumedCompactionState : undefined);
|
||||
const prepareTurn = createCompactionStateAwarePrepareTurn({
|
||||
compact,
|
||||
getState: () => activeSessionRef?.compactionState,
|
||||
saveState: async (state) => {
|
||||
const activeSession = activeSessionRef;
|
||||
if (!activeSession) return;
|
||||
const stateForSession = {
|
||||
...state,
|
||||
conversation_id: activeSession.sessionId,
|
||||
};
|
||||
try {
|
||||
const result = await this.persistActiveSessionCompactionState(
|
||||
activeSession,
|
||||
stateForSession,
|
||||
);
|
||||
if (!result.updated) {
|
||||
configWithProvider.logger?.debug?.(
|
||||
"Skipped stale session compaction state",
|
||||
{
|
||||
sessionId: activeSession.sessionId,
|
||||
sourceMessageCount: stateForSession.source_message_count,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
configWithProvider.logger?.error?.(
|
||||
"Failed to persist session compaction state",
|
||||
{ sessionId: activeSession.sessionId, error },
|
||||
);
|
||||
captureSdkError(configWithProvider.telemetry, {
|
||||
component: "core",
|
||||
operation: "session.persist_compaction_state",
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
error,
|
||||
context: {
|
||||
sessionId: activeSession.sessionId,
|
||||
providerId: configWithProvider.providerId,
|
||||
modelId: configWithProvider.modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
clearState: async () => {
|
||||
const activeSession = activeSessionRef;
|
||||
if (!activeSession?.compactionState) return;
|
||||
try {
|
||||
await this.clearActiveSessionCompactionState(activeSession);
|
||||
} catch (error) {
|
||||
configWithProvider.logger?.error?.(
|
||||
"Failed to delete stale session compaction state",
|
||||
{ sessionId: activeSession.sessionId, error },
|
||||
);
|
||||
captureSdkError(configWithProvider.telemetry, {
|
||||
component: "core",
|
||||
operation: "session.delete_compaction_state",
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
error,
|
||||
context: {
|
||||
sessionId: activeSession.sessionId,
|
||||
providerId: configWithProvider.providerId,
|
||||
modelId: configWithProvider.modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const agentConfig = {
|
||||
sessionId,
|
||||
@@ -436,7 +537,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
systemPrompt: configWithProvider.systemPrompt,
|
||||
maxIterations: configWithProvider.maxIterations,
|
||||
execution: configWithProvider.execution,
|
||||
prepareTurn: createContextCompactionPrepareTurn(configWithProvider),
|
||||
prepareTurn,
|
||||
tools,
|
||||
hooks: bootstrap.hooks,
|
||||
extensions,
|
||||
@@ -580,6 +681,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
aborting: false,
|
||||
interactive: input.interactive === true,
|
||||
persistedMessages: initialMessages,
|
||||
compactionState: initialCompactionState,
|
||||
activeTeamRunIds: new Set<string>(),
|
||||
pendingTeamRunUpdates: [],
|
||||
teamRunWaiters: [],
|
||||
@@ -589,6 +691,25 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
submitAndExitObserved: false,
|
||||
lastInteractiveTurnFinishReason: undefined,
|
||||
};
|
||||
activeSessionRef = active;
|
||||
if (
|
||||
active.compactionState &&
|
||||
!this.isCompactionStateForSession(
|
||||
active.sessionId,
|
||||
active.compactionState,
|
||||
active,
|
||||
)
|
||||
) {
|
||||
active.config.logger?.log?.(
|
||||
"Ignoring session compaction state for a different conversation",
|
||||
{
|
||||
severity: "warn",
|
||||
sessionId: active.sessionId,
|
||||
conversationId: active.compactionState.conversation_id,
|
||||
},
|
||||
);
|
||||
active.compactionState = undefined;
|
||||
}
|
||||
this.sessions.set(sessionId, active);
|
||||
this.emitStatus(sessionId, "running");
|
||||
if (initialMessages.length > 0 && !resumedArtifacts) {
|
||||
@@ -599,6 +720,15 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
initialMessages,
|
||||
active.config.systemPrompt,
|
||||
);
|
||||
if (active.compactionState) {
|
||||
const result = await this.persistActiveSessionCompactionState(
|
||||
active,
|
||||
active.compactionState,
|
||||
);
|
||||
if (!result.updated) {
|
||||
active.compactionState = undefined;
|
||||
}
|
||||
}
|
||||
if (!startInput.prompt?.trim()) {
|
||||
await this.updateStatus(active, "completed", 0);
|
||||
}
|
||||
@@ -883,6 +1013,168 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
return { updated: result?.updated === true };
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return { updated: false };
|
||||
const activeSession = this.sessions.get(target);
|
||||
const sessionRecord = activeSession
|
||||
? undefined
|
||||
: await this.getSession(target);
|
||||
const existing = activeSession ?? sessionRecord;
|
||||
if (!existing) return { updated: false };
|
||||
if (
|
||||
!(await this.canPersistCompactionState(
|
||||
target,
|
||||
state,
|
||||
activeSession,
|
||||
sessionRecord,
|
||||
))
|
||||
) {
|
||||
return { updated: false };
|
||||
}
|
||||
if (activeSession) {
|
||||
return await this.persistActiveSessionCompactionState(
|
||||
activeSession,
|
||||
state,
|
||||
);
|
||||
}
|
||||
const current = await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
target,
|
||||
);
|
||||
if (isIncomingCompactionStateStale(state, current)) {
|
||||
return { updated: false };
|
||||
}
|
||||
await this.invoke<void>("persistSessionCompactionState", target, state);
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return undefined;
|
||||
const activeSession = this.sessions.get(target);
|
||||
if (activeSession) {
|
||||
for (;;) {
|
||||
const pendingWrite = activeSession.compactionStateWriteQueue;
|
||||
if (!pendingWrite) {
|
||||
return activeSession.compactionState;
|
||||
}
|
||||
await pendingWrite.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
private isCompactionStateForSession(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
activeSession?: ActiveSession,
|
||||
sessionRecord?: SessionRecord,
|
||||
): boolean {
|
||||
const conversationId = state.conversation_id?.trim();
|
||||
if (!conversationId) {
|
||||
return true;
|
||||
}
|
||||
if (conversationId === sessionId) {
|
||||
return true;
|
||||
}
|
||||
const expectedConversationId =
|
||||
activeSession?.agent.getConversationId()?.trim() ||
|
||||
sessionRecord?.conversationId?.trim();
|
||||
return expectedConversationId
|
||||
? conversationId === expectedConversationId
|
||||
: false;
|
||||
}
|
||||
|
||||
private async canPersistCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
activeSession?: ActiveSession,
|
||||
sessionRecord?: SessionRecord,
|
||||
): Promise<boolean> {
|
||||
if (!state.conversation_id?.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isCompactionStateForSession(
|
||||
sessionId,
|
||||
state,
|
||||
activeSession,
|
||||
sessionRecord,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sourceMessages =
|
||||
activeSession?.agent.getMessages() ??
|
||||
(await this.readSessionMessages(sessionId));
|
||||
return projectSessionCompactionState(state, sourceMessages) !== undefined;
|
||||
}
|
||||
|
||||
private async persistActiveSessionCompactionState(
|
||||
session: ActiveSession,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }> {
|
||||
if (!(await this.canPersistCompactionState(session.sessionId, state, session))) {
|
||||
return { updated: false };
|
||||
}
|
||||
return await this.enqueueCompactionStateWrite(session, async () => {
|
||||
if (isIncomingCompactionStateStale(state, session.compactionState)) {
|
||||
return { updated: false };
|
||||
}
|
||||
await this.invoke<void>(
|
||||
"persistSessionCompactionState",
|
||||
session.sessionId,
|
||||
state,
|
||||
);
|
||||
session.compactionState = state;
|
||||
return { updated: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async clearActiveSessionCompactionState(
|
||||
session: ActiveSession,
|
||||
): Promise<void> {
|
||||
await this.enqueueCompactionStateWrite(session, async () => {
|
||||
if (!session.compactionState) {
|
||||
return;
|
||||
}
|
||||
await this.invoke<void>(
|
||||
"deleteSessionCompactionState",
|
||||
session.sessionId,
|
||||
);
|
||||
session.compactionState = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async enqueueCompactionStateWrite<T>(
|
||||
session: ActiveSession,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = session.compactionStateWriteQueue ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(action);
|
||||
const tracked = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
session.compactionStateWriteQueue = tracked;
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (session.compactionStateWriteQueue === tracked) {
|
||||
session.compactionStateWriteQueue = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async readSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<LlmsProviders.Message[]> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import type { HookEventPayload } from "../../hooks";
|
||||
import type { CheckpointEntry } from "../../hooks/checkpoint-hooks";
|
||||
import type { ProviderSettings } from "../../services/llms/provider-settings";
|
||||
import type { SessionCompactionState } from "../../session/models/session-compaction";
|
||||
import type { SessionManifest } from "../../session/models/session-manifest";
|
||||
import type { SessionSource } from "../../types/common";
|
||||
import type { CoreSessionConfig } from "../../types/config";
|
||||
@@ -104,6 +105,7 @@ export interface StartSessionInput {
|
||||
interactive?: boolean;
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: LlmsProviders.Message[];
|
||||
initialCompactionState?: SessionCompactionState;
|
||||
userImages?: string[];
|
||||
userFiles?: string[];
|
||||
/**
|
||||
@@ -308,6 +310,13 @@ export interface RuntimeHost {
|
||||
title?: string | null;
|
||||
},
|
||||
): Promise<{ updated: boolean }>;
|
||||
updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }>;
|
||||
readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined>;
|
||||
readSessionMessages(sessionId: string): Promise<LlmsProviders.Message[]>;
|
||||
dispatchHookEvent(payload: HookEventPayload): Promise<void>;
|
||||
subscribe(
|
||||
|
||||
@@ -128,6 +128,55 @@ describe("RuntimeEventAdapter — suppressed events", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeEventAdapter — status notices", () => {
|
||||
let adapter: RuntimeEventAdapter;
|
||||
beforeEach(() => {
|
||||
adapter = new RuntimeEventAdapter();
|
||||
});
|
||||
|
||||
it("preserves bounded compaction reasons", () => {
|
||||
for (const reason of [
|
||||
"auto_compaction",
|
||||
"manual_compaction",
|
||||
"compaction_budget_emergency",
|
||||
] as const) {
|
||||
const out = adapter.translate({
|
||||
type: "status-notice",
|
||||
snapshot: makeSnapshot(),
|
||||
message: "compaction status",
|
||||
metadata: { reason },
|
||||
});
|
||||
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction status",
|
||||
reason,
|
||||
metadata: { reason },
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not promote arbitrary status reasons", () => {
|
||||
const out = adapter.translate({
|
||||
type: "status-notice",
|
||||
snapshot: makeSnapshot(),
|
||||
message: "custom",
|
||||
metadata: { reason: "surprise" },
|
||||
});
|
||||
|
||||
expect(out[0]).toMatchObject({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
message: "custom",
|
||||
reason: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Iteration lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -66,6 +66,21 @@ import type {
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
type StatusNoticeReason = Extract<AgentEvent, { type: "notice" }>["reason"];
|
||||
|
||||
function resolveStatusNoticeReason(
|
||||
reason: unknown,
|
||||
): StatusNoticeReason | undefined {
|
||||
switch (reason) {
|
||||
case "auto_compaction":
|
||||
case "manual_compaction":
|
||||
case "compaction_budget_emergency":
|
||||
return reason;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTextPart(message: AgentMessage): string | undefined {
|
||||
const parts = message.content.filter(
|
||||
(part): part is AgentTextPart => part.type === "text",
|
||||
@@ -225,10 +240,7 @@ export class RuntimeEventAdapter {
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: event.message,
|
||||
reason:
|
||||
event.metadata?.reason === "auto_compaction"
|
||||
? "auto_compaction"
|
||||
: undefined,
|
||||
reason: resolveStatusNoticeReason(event.metadata?.reason),
|
||||
metadata: event.metadata,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -79,6 +79,13 @@ export class SessionArtifacts {
|
||||
);
|
||||
}
|
||||
|
||||
public sessionCompactionPath(sessionId: string): string {
|
||||
return join(
|
||||
this.sessionArtifactsDir(sessionId),
|
||||
`${sessionId}.compaction.json`,
|
||||
);
|
||||
}
|
||||
|
||||
public sessionManifestPath(sessionId: string, ensureDir = false): string {
|
||||
const base = ensureDir
|
||||
? this.ensureSessionArtifactsDir(sessionId)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ITelemetryService } from "@cline/shared";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
CORE_TELEMETRY_EVENTS,
|
||||
captureCompactionBudgetEmergency,
|
||||
captureCompactionExecuted,
|
||||
captureCompactionSkipped,
|
||||
captureExtensionActivated,
|
||||
@@ -441,6 +442,38 @@ describe("captureRunCommandsTimeout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureCompactionBudgetEmergency", () => {
|
||||
test("emits task.compaction_budget_emergency with action metadata", () => {
|
||||
const stub = createTelemetryStub();
|
||||
captureCompactionBudgetEmergency(stub.telemetry, {
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 2,
|
||||
warningCount: 1,
|
||||
liveTailHandling: "included_degraded",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4",
|
||||
});
|
||||
|
||||
const { event, properties } = captureCallAt(stub, 0);
|
||||
expect(event).toBe("task.compaction_budget_emergency");
|
||||
expect(properties).toMatchObject({
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 2,
|
||||
warningCount: 1,
|
||||
liveTailHandling: "included_degraded",
|
||||
});
|
||||
expect(typeof (properties as Record<string, unknown>).timestamp).toBe(
|
||||
"string",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Telemetry-policy regression coverage.
|
||||
*
|
||||
@@ -603,6 +636,24 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
|
||||
expect(emitRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("captureCompactionBudgetEmergency never invokes captureRequired", () => {
|
||||
const { adapter, emitRequired } = createDisabledAdapter();
|
||||
const service = new TelemetryService({
|
||||
distinctId: "test-distinct-id",
|
||||
adapters: [adapter],
|
||||
});
|
||||
captureCompactionBudgetEmergency(service, {
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 1,
|
||||
warningCount: 0,
|
||||
liveTailHandling: "included_degraded",
|
||||
});
|
||||
expect(emitRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a correctly-policed adapter drops these events when disabled", () => {
|
||||
// This test layers on top of the previous four to assert the *full*
|
||||
// end-to-end policy: when the adapter is disabled, a real adapter
|
||||
@@ -681,6 +732,15 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
|
||||
command_count: 2,
|
||||
duration_ms: 1502,
|
||||
});
|
||||
captureCompactionBudgetEmergency(service, {
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 1,
|
||||
warningCount: 0,
|
||||
liveTailHandling: "included_degraded",
|
||||
});
|
||||
expect(observed).toEqual([]);
|
||||
expect(dropped).toEqual([
|
||||
"user.extension_activated",
|
||||
@@ -691,6 +751,7 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
|
||||
"task.compaction_executed",
|
||||
"task.compaction_skipped",
|
||||
"sdk.tool_timeout",
|
||||
"task.compaction_budget_emergency",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
SDK_ERROR_TELEMETRY_EVENT,
|
||||
type TelemetryProperties,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
CoreCompactionBudgetPolicyIntent,
|
||||
CoreCompactionLiveTailHandling,
|
||||
} from "../../types/config";
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -61,6 +65,7 @@ export const CORE_TELEMETRY_EVENTS = {
|
||||
SUBAGENT_COMPLETED: "task.subagent_completed",
|
||||
COMPACTION_EXECUTED: "task.compaction_executed",
|
||||
COMPACTION_SKIPPED: "task.compaction_skipped",
|
||||
COMPACTION_BUDGET_EMERGENCY: "task.compaction_budget_emergency",
|
||||
},
|
||||
HOOKS: {
|
||||
DISCOVERY_COMPLETED: "hooks.discovery_completed",
|
||||
@@ -678,3 +683,26 @@ export function captureCompactionSkipped(
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export interface CaptureCompactionBudgetEmergencyProperties {
|
||||
ulid: string;
|
||||
strategy: TelemetryCompactionStrategy;
|
||||
mode: TelemetryCompactionMode;
|
||||
policyIntent: CoreCompactionBudgetPolicyIntent;
|
||||
actionCount: number;
|
||||
warningCount: number;
|
||||
liveTailHandling: CoreCompactionLiveTailHandling;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export function captureCompactionBudgetEmergency(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
properties: CaptureCompactionBudgetEmergencyProperties &
|
||||
Partial<TelemetryAgentIdentityProperties>,
|
||||
): void {
|
||||
emit(telemetry, CORE_TELEMETRY_EVENTS.TASK.COMPACTION_BUDGET_EMERGENCY, {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
parseSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
} from "./session-compaction";
|
||||
|
||||
describe("session compaction state", () => {
|
||||
it("rejects projection when the canonical prefix was edited before the boundary", () => {
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "original detail" },
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const editedPrefix = [
|
||||
{ ...sourceMessages[0], content: "redacted detail" },
|
||||
sourceMessages[1],
|
||||
{ id: "u2", role: "user" as const, content: "tail" },
|
||||
];
|
||||
|
||||
expect(projectSessionCompactionState(state, editedPrefix)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("projects compacted state when the canonical prefix matches exactly", () => {
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "original detail" },
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const tail = { id: "u2", role: "user" as const, content: "tail" };
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(
|
||||
projectSessionCompactionState(state, [...sourceMessages, tail]),
|
||||
).toEqual([...compactedMessages, tail]);
|
||||
});
|
||||
|
||||
it("projects when resumed user input was display-normalized from persisted history", () => {
|
||||
const sourceMessages = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: '<user_input mode="act">hello</user_input>',
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: '<user_input mode="act">inspect</user_input>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const resumedMessages = [
|
||||
{ ...sourceMessages[0], content: "hello" },
|
||||
{
|
||||
...sourceMessages[1],
|
||||
content: [{ type: "text" as const, text: "inspect" }],
|
||||
},
|
||||
sourceMessages[2],
|
||||
{ id: "u3", role: "user" as const, content: "tail" },
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(projectSessionCompactionState(state, resumedMessages)).toEqual([
|
||||
...compactedMessages,
|
||||
resumedMessages[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects anchor-free sidecars even when the source count is zero", () => {
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
source_message_count: 0,
|
||||
messages: [
|
||||
{ id: "summary", role: "user" as const, content: "unanchored" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(state).toBeDefined();
|
||||
if (!state) {
|
||||
throw new Error("expected parsed compaction state");
|
||||
}
|
||||
expect(
|
||||
projectSessionCompactionState(state, [
|
||||
{ id: "u1", role: "user", content: "canonical" },
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects malformed sidecar timestamps", () => {
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
updated_at: "not-a-date",
|
||||
source_message_count: 1,
|
||||
source_prefix_hash: "sha256:test",
|
||||
messages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
} from "@cline/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
function isMessageWithMetadata(value: unknown): value is MessageWithMetadata {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<MessageWithMetadata>;
|
||||
if (candidate.role !== "user" && candidate.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof candidate.content === "string" || Array.isArray(candidate.content)
|
||||
);
|
||||
}
|
||||
|
||||
const MessageWithMetadataSchema = z.custom<MessageWithMetadata>(
|
||||
isMessageWithMetadata,
|
||||
);
|
||||
|
||||
export const SessionCompactionStateSchema = z.object({
|
||||
version: z.literal(1),
|
||||
updated_at: z.string().datetime(),
|
||||
conversation_id: z.string().min(1).optional(),
|
||||
source_message_count: z.number().int().nonnegative(),
|
||||
source_prefix_hash: z.string().min(1).optional(),
|
||||
source_last_message_key: z.string().min(1).optional(),
|
||||
messages: z.array(MessageWithMetadataSchema),
|
||||
system_prompt: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SessionCompactionState = z.infer<
|
||||
typeof SessionCompactionStateSchema
|
||||
>;
|
||||
|
||||
function cloneMessages(
|
||||
messages: readonly MessageWithMetadata[],
|
||||
): MessageWithMetadata[] {
|
||||
return JSON.parse(canonicalJson(messages)) as MessageWithMetadata[];
|
||||
}
|
||||
|
||||
function toCanonicalJsonValue(value: unknown, seen: WeakSet<object>): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
throw new TypeError("Cannot serialize bigint in session compaction state");
|
||||
}
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === "function" ||
|
||||
typeof value === "symbol"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const withToJson = value as { toJSON?: () => unknown };
|
||||
if (typeof withToJson.toJSON === "function") {
|
||||
const jsonValue = withToJson.toJSON();
|
||||
if (jsonValue !== value) {
|
||||
return toCanonicalJsonValue(jsonValue, seen);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new TypeError("Cannot serialize circular session compaction state");
|
||||
}
|
||||
seen.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
const normalized = toCanonicalJsonValue(item, seen);
|
||||
return normalized === undefined ? null : normalized;
|
||||
});
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(record).sort()) {
|
||||
const item = toCanonicalJsonValue(record[key], seen);
|
||||
if (item !== undefined) {
|
||||
normalized[key] = item;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
} finally {
|
||||
seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
const json = JSON.stringify(
|
||||
toCanonicalJsonValue(value, new WeakSet<object>()),
|
||||
);
|
||||
if (json === undefined) {
|
||||
throw new TypeError("Cannot serialize undefined session compaction state");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function normalizeMessageForSourceHash(
|
||||
message: MessageWithMetadata,
|
||||
): MessageWithMetadata {
|
||||
if (message.role !== "user") {
|
||||
return message;
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
...message,
|
||||
content: formatDisplayUserInput(message.content),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { ...part, text: formatDisplayUserInput(part.text) }
|
||||
: part,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function messageBoundaryKey(message: MessageWithMetadata | undefined): string {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
const normalized = normalizeMessageForSourceHash(message);
|
||||
if (typeof normalized.id === "string" && normalized.id.trim()) {
|
||||
return `id:${normalized.id.trim()}`;
|
||||
}
|
||||
if (typeof normalized.ts === "number" && Number.isFinite(normalized.ts)) {
|
||||
return `ts:${normalized.role}:${normalized.ts}`;
|
||||
}
|
||||
return `content:${normalized.role}:${JSON.stringify(normalized.content)}`;
|
||||
}
|
||||
|
||||
function sourcePrefixHash(
|
||||
messages: readonly MessageWithMetadata[],
|
||||
count = messages.length,
|
||||
): string {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("cline-session-compaction-source-v1\n");
|
||||
hash.update(`${count}\n`);
|
||||
for (const message of messages.slice(0, count)) {
|
||||
hash.update(canonicalJson(normalizeMessageForSourceHash(message)));
|
||||
hash.update("\n");
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createSessionCompactionState(input: {
|
||||
sourceMessages: readonly MessageWithMetadata[];
|
||||
compactedMessages: readonly MessageWithMetadata[];
|
||||
conversationId?: string;
|
||||
systemPrompt?: string;
|
||||
updatedAt?: string;
|
||||
}): SessionCompactionState {
|
||||
const lastSourceMessage = input.sourceMessages.at(-1);
|
||||
const sourceLastMessageKey = messageBoundaryKey(lastSourceMessage);
|
||||
return SessionCompactionStateSchema.parse({
|
||||
version: 1,
|
||||
updated_at: input.updatedAt ?? new Date().toISOString(),
|
||||
...(input.conversationId?.trim()
|
||||
? { conversation_id: input.conversationId.trim() }
|
||||
: {}),
|
||||
source_message_count: input.sourceMessages.length,
|
||||
source_prefix_hash: sourcePrefixHash(input.sourceMessages),
|
||||
...(sourceLastMessageKey
|
||||
? { source_last_message_key: sourceLastMessageKey }
|
||||
: {}),
|
||||
messages: cloneMessages(input.compactedMessages),
|
||||
...(input.systemPrompt !== undefined
|
||||
? { system_prompt: input.systemPrompt }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function projectSessionCompactionState(
|
||||
state: SessionCompactionState,
|
||||
sourceMessages: readonly MessageWithMetadata[],
|
||||
): MessageWithMetadata[] | undefined {
|
||||
if (state.source_message_count > sourceMessages.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.source_prefix_hash) {
|
||||
if (
|
||||
sourcePrefixHash(sourceMessages, state.source_message_count) !==
|
||||
state.source_prefix_hash
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
} else if (state.source_message_count > 0 && state.source_last_message_key) {
|
||||
const boundary = sourceMessages[state.source_message_count - 1];
|
||||
if (messageBoundaryKey(boundary) !== state.source_last_message_key) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
...cloneMessages(state.messages),
|
||||
...cloneMessages(sourceMessages.slice(state.source_message_count)),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseSessionCompactionState(
|
||||
value: unknown,
|
||||
): SessionCompactionState | undefined {
|
||||
const parsed = SessionCompactionStateSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const SessionManifestSchema = z.object({
|
||||
prompt: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
messages_path: z.string().min(1).optional(),
|
||||
compaction_path: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type SessionManifest = z.infer<typeof SessionManifestSchema>;
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface CreateRootSessionWithArtifactsInput {
|
||||
export interface RootSessionArtifacts {
|
||||
manifestPath: string;
|
||||
messagesPath: string;
|
||||
compactionPath?: string;
|
||||
manifest: SessionManifest;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SqliteSessionStore } from "../../services/storage/sqlite-session-store";
|
||||
import { SessionSource } from "../../types/common";
|
||||
import { createSessionCompactionState } from "../models/session-compaction";
|
||||
import { FileSessionService } from "../services/file-session-service";
|
||||
import { CoreSessionService } from "../services/session-service";
|
||||
|
||||
@@ -32,6 +39,149 @@ describe("UnifiedSessionPersistenceService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("persists compaction state as a separate session artifact", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-artifact-"));
|
||||
tempDirs.push(sessionsDir);
|
||||
const service = new FileSessionService(sessionsDir);
|
||||
const sessionId = "session-with-compaction";
|
||||
const artifacts = await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: false,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "full transcript" },
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
conversationId: "conv-1",
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await service.persistSessionMessages(sessionId, sourceMessages);
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
|
||||
const messagesPayload = JSON.parse(
|
||||
readFileSync(artifacts.messagesPath, "utf8"),
|
||||
) as { messages?: unknown[] };
|
||||
const compactionPayload = JSON.parse(
|
||||
readFileSync(artifacts.compactionPath ?? "", "utf8"),
|
||||
) as { messages?: unknown[]; source_message_count?: number };
|
||||
expect(messagesPayload.messages).toHaveLength(1);
|
||||
expect(compactionPayload).toMatchObject({
|
||||
source_message_count: 1,
|
||||
messages: compactedMessages,
|
||||
});
|
||||
await expect(
|
||||
service.readSessionCompactionState(sessionId),
|
||||
).resolves.toMatchObject({
|
||||
source_message_count: 1,
|
||||
messages: compactedMessages,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes persisted compaction state without mutating canonical messages", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-delete-"));
|
||||
tempDirs.push(sessionsDir);
|
||||
const service = new FileSessionService(sessionsDir);
|
||||
const sessionId = "session-delete-compaction";
|
||||
const artifacts = await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: false,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "full transcript" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await service.persistSessionMessages(sessionId, sourceMessages);
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
await service.deleteSessionCompactionState(sessionId);
|
||||
|
||||
expect(existsSync(artifacts.messagesPath)).toBe(true);
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(false);
|
||||
await expect(
|
||||
service.readSessionCompactionState(sessionId),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists compaction state without backfilling old manifests during path resolution", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-old-manifest-"));
|
||||
tempDirs.push(sessionsDir);
|
||||
const service = new FileSessionService(sessionsDir);
|
||||
const sessionId = "session-old-manifest";
|
||||
const artifacts = await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: false,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(artifacts.manifestPath, "utf8"),
|
||||
) as {
|
||||
compaction_path?: string;
|
||||
};
|
||||
delete manifest.compaction_path;
|
||||
writeFileSync(
|
||||
artifacts.manifestPath,
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [
|
||||
{ id: "u1", role: "user" as const, content: "full transcript" },
|
||||
],
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(true);
|
||||
expect(
|
||||
JSON.parse(readFileSync(artifacts.manifestPath, "utf8")),
|
||||
).not.toHaveProperty("compaction_path");
|
||||
});
|
||||
|
||||
sqliteIt(
|
||||
"reconciles dead running sessions into failed manifests with terminal markers",
|
||||
async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
SessionPersistenceAdapter,
|
||||
StoredMessageWithMetadata,
|
||||
} from "../../types/session";
|
||||
import type { SessionCompactionState } from "../models/session-compaction";
|
||||
import type { SessionRow } from "../models/session-row";
|
||||
import { SessionManifestStore } from "../stores/session-manifest-store";
|
||||
import { TeamChildSessionManager } from "../team";
|
||||
@@ -109,6 +110,8 @@ export class UnifiedSessionPersistenceService {
|
||||
providedId.length > 0 ? providedId : `${Date.now()}_${nanoid(5)}`;
|
||||
const messagesPath =
|
||||
this.manifestStore.artifacts.sessionMessagesPath(sessionId);
|
||||
const compactionPath =
|
||||
this.manifestStore.artifacts.sessionCompactionPath(sessionId);
|
||||
const manifestPath =
|
||||
this.manifestStore.artifacts.sessionManifestPath(sessionId);
|
||||
const metadata = resolveMetadataWithTitle({
|
||||
@@ -134,6 +137,7 @@ export class UnifiedSessionPersistenceService {
|
||||
prompt: input.prompt?.trim() || undefined,
|
||||
metadata,
|
||||
messages_path: messagesPath,
|
||||
compaction_path: compactionPath,
|
||||
};
|
||||
|
||||
await this.adapter.upsertSession({
|
||||
@@ -172,7 +176,7 @@ export class UnifiedSessionPersistenceService {
|
||||
startedAt,
|
||||
);
|
||||
this.manifestStore.writeSessionManifest(manifestPath, manifest);
|
||||
return { manifestPath, messagesPath, manifest };
|
||||
return { manifestPath, messagesPath, compactionPath, manifest };
|
||||
}
|
||||
|
||||
async updateSessionStatus(
|
||||
@@ -320,6 +324,23 @@ export class UnifiedSessionPersistenceService {
|
||||
);
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
return await this.manifestStore.readSessionCompactionState(sessionId);
|
||||
}
|
||||
|
||||
async persistSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<void> {
|
||||
await this.manifestStore.persistSessionCompactionState(sessionId, state);
|
||||
}
|
||||
|
||||
async deleteSessionCompactionState(sessionId: string): Promise<void> {
|
||||
await this.manifestStore.deleteSessionCompactionState(sessionId);
|
||||
}
|
||||
|
||||
applySubagentStatus(
|
||||
subSessionId: string,
|
||||
event: HookEventPayload,
|
||||
@@ -547,6 +568,9 @@ export class UnifiedSessionPersistenceService {
|
||||
children.map(async (child) => {
|
||||
await deleteCheckpointRefs(child.cwd, child.sessionId);
|
||||
unlinkIfExists(child.messagesPath);
|
||||
await this.manifestStore.deleteSessionCompactionState(
|
||||
child.sessionId,
|
||||
);
|
||||
unlinkIfExists(
|
||||
this.manifestStore.artifacts.sessionManifestPath(
|
||||
child.sessionId,
|
||||
@@ -561,6 +585,7 @@ export class UnifiedSessionPersistenceService {
|
||||
await deleteCheckpointRefs(row.cwd, id);
|
||||
|
||||
unlinkIfExists(row.messagesPath);
|
||||
await this.manifestStore.deleteSessionCompactionState(id);
|
||||
unlinkIfExists(this.manifestStore.artifacts.sessionManifestPath(id, false));
|
||||
if (row.isSubagent) {
|
||||
this.manifestStore.artifacts.removeSessionDirIfEmpty(id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import type { BasicLogger } from "@cline/shared";
|
||||
@@ -20,11 +22,72 @@ import type {
|
||||
SessionPersistenceAdapter,
|
||||
StoredMessageWithMetadata,
|
||||
} from "../../types/session";
|
||||
import {
|
||||
parseSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
SessionCompactionStateSchema,
|
||||
} from "../models/session-compaction";
|
||||
import {
|
||||
type SessionManifest,
|
||||
SessionManifestSchema,
|
||||
} from "../models/session-manifest";
|
||||
|
||||
async function fsyncBestEffort(path: string): Promise<void> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(path, "r");
|
||||
await handle.sync();
|
||||
} catch {
|
||||
// Directory fsync is not available on all platforms/filesystems.
|
||||
} finally {
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Best-effort durability only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomic(path: string, contents: string): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(tempPath, "w");
|
||||
await handle.writeFile(contents, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await rename(tempPath, path);
|
||||
await fsyncBestEffort(dirname(path));
|
||||
} catch (error) {
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Preserve the original write error.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(tempPath, { force: true });
|
||||
} catch {
|
||||
// Preserve the original write error.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export class SessionManifestStore {
|
||||
readonly artifacts: SessionArtifacts;
|
||||
|
||||
@@ -135,6 +198,49 @@ export class SessionManifestStore {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCompactionPath(sessionId: string): string {
|
||||
const { manifest } = this.readManifestFile(sessionId);
|
||||
return (
|
||||
manifest?.compaction_path?.trim() ||
|
||||
this.artifacts.sessionCompactionPath(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
const path = this.resolveCompactionPath(sessionId);
|
||||
try {
|
||||
return parseSessionCompactionState(
|
||||
JSON.parse(await readFile(path, "utf8")) as unknown,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
this.logger?.debug("Ignoring invalid session compaction state", {
|
||||
sessionId,
|
||||
path,
|
||||
error,
|
||||
recovery: "Canonical history is unchanged; deleting the sidecar is safe.",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async persistSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<void> {
|
||||
const path = this.resolveCompactionPath(sessionId);
|
||||
const payload = SessionCompactionStateSchema.parse(state);
|
||||
await writeFileAtomic(path, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async deleteSessionCompactionState(sessionId: string): Promise<void> {
|
||||
await rm(this.resolveCompactionPath(sessionId), { force: true });
|
||||
}
|
||||
|
||||
appendStaleSessionHookLog(
|
||||
detectedAt: string,
|
||||
sessionId: string,
|
||||
|
||||
@@ -64,8 +64,32 @@ export interface CoreCompactionContext {
|
||||
utilizationRatio: number;
|
||||
}
|
||||
|
||||
// Mirrors BudgetPolicyIntent in extensions/context/budget-projection/types.ts.
|
||||
// Keep this public API type decoupled from the internal projection module.
|
||||
export type CoreCompactionBudgetPolicyIntent =
|
||||
| "agentic_summary"
|
||||
| "basic_compaction_projection"
|
||||
| "normal_provider_request";
|
||||
|
||||
// Mirrors LiveTailHandling in extensions/context/budget-projection/types.ts.
|
||||
// Keep this public API type decoupled from the internal projection module.
|
||||
export type CoreCompactionLiveTailHandling =
|
||||
| "included_verbatim"
|
||||
| "included_degraded"
|
||||
| "summarized_as_context"
|
||||
| "omitted_with_warning"
|
||||
| "preserved_out_of_band";
|
||||
|
||||
export interface CoreCompactionBudgetMetadata {
|
||||
policyIntent: CoreCompactionBudgetPolicyIntent;
|
||||
actionCount: number;
|
||||
warningCount: number;
|
||||
liveTailHandling: CoreCompactionLiveTailHandling;
|
||||
}
|
||||
|
||||
export interface CoreCompactionResult {
|
||||
messages: MessageWithMetadata[];
|
||||
budget?: CoreCompactionBudgetMetadata;
|
||||
}
|
||||
|
||||
export interface CoreCompactionSummarizerConfig {
|
||||
@@ -74,6 +98,13 @@ export interface CoreCompactionSummarizerConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Optional pre-resolved model metadata for the summarizer. Supplying either
|
||||
* this or `knownModels` lets agentic compaction budget summary input against
|
||||
* the summarizer model's actual context window instead of falling back to the
|
||||
* active model's window.
|
||||
*/
|
||||
modelInfo?: ModelInfo;
|
||||
knownModels?: Record<string, ModelInfo>;
|
||||
providerConfig?: ProviderConfig;
|
||||
maxOutputTokens?: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentFinishReason } from "@cline/shared";
|
||||
import type { SessionAccumulatedUsage } from "../runtime/host/runtime-host";
|
||||
import type { BuiltRuntime } from "../runtime/orchestration/session-runtime";
|
||||
import type { SessionRuntime } from "../runtime/orchestration/session-runtime-orchestrator";
|
||||
import type { SessionCompactionState } from "../session/models/session-compaction";
|
||||
import type { SessionRow } from "../session/models/session-row";
|
||||
import type { RootSessionArtifacts } from "../session/services/session-service";
|
||||
import type { SessionSource, SessionStatus } from "./common";
|
||||
@@ -26,6 +27,8 @@ export type ActiveSession = {
|
||||
aborting: boolean;
|
||||
interactive: boolean;
|
||||
persistedMessages?: LlmsProviders.MessageWithMetadata[];
|
||||
compactionState?: SessionCompactionState;
|
||||
compactionStateWriteQueue?: Promise<void>;
|
||||
activeTeamRunIds: Set<string>;
|
||||
pendingTeamRunUpdates: TeamRunUpdate[];
|
||||
teamRunWaiters: Array<() => void>;
|
||||
|
||||
@@ -435,9 +435,9 @@ export interface AgentRuntimeConfig {
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult> | ToolApprovalResult;
|
||||
/**
|
||||
* Optional host-owned context pipeline that can rewrite the transcript
|
||||
* before each model request. When it returns messages, the runtime replaces
|
||||
* its in-memory transcript so compaction persists into the final run result.
|
||||
* Optional host-owned context pipeline that can project the transcript before
|
||||
* each model request. Returned messages affect the provider request only; the
|
||||
* runtime's canonical in-memory transcript remains append-only.
|
||||
*/
|
||||
prepareTurn?: (
|
||||
context: AgentRuntimePrepareTurnContext,
|
||||
|
||||
@@ -162,7 +162,9 @@ export interface AgentNoticeEvent extends AgentEventMetadata {
|
||||
| "completion_without_submit"
|
||||
| "tool_execution_failed"
|
||||
| "mistake_limit"
|
||||
| "auto_compaction";
|
||||
| "auto_compaction"
|
||||
| "manual_compaction"
|
||||
| "compaction_budget_emergency";
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -800,8 +802,9 @@ export interface AgentConfig {
|
||||
*/
|
||||
logger?: BasicLogger;
|
||||
/**
|
||||
* Optional callback that can rewrite the turn input before each model call.
|
||||
* This is the primary seam for host-owned context pipelines.
|
||||
* Optional callback that can project the turn input before each model call.
|
||||
* Returned messages affect the provider request only; the canonical runtime
|
||||
* transcript remains append-only.
|
||||
*/
|
||||
prepareTurn?: (
|
||||
context: AgentPrepareTurnContext,
|
||||
|
||||
@@ -313,6 +313,8 @@ export type HubCommandName =
|
||||
| "session.restore"
|
||||
| "session.delete"
|
||||
| "session.update"
|
||||
| "session.compaction.get"
|
||||
| "session.compaction.update"
|
||||
| "session.pending_prompts"
|
||||
| "session.update_pending_prompt"
|
||||
| "session.remove_pending_prompt"
|
||||
|
||||
Reference in New Issue
Block a user