mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48ba92357 | ||
|
|
3693d2f867 | ||
|
|
86aca36d03 | ||
|
|
d5db7eb853 | ||
|
|
11d5ebe8bc | ||
|
|
6f7cc4907f | ||
|
|
27e3541569 | ||
|
|
ae9c5b4d9d |
@@ -1,6 +1,9 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
|
||||
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
|
||||
- Polished the status bar usage display and ClinePass model name
|
||||
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
|
||||
- The thinking-level picker now defaults its cursor to Medium instead of Off
|
||||
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
|
||||
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
|
||||
|
||||
## 3.0.37
|
||||
|
||||
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.37",
|
||||
"version": "3.0.38",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -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,81 +1,89 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} 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(config = createConfig()): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
@@ -84,6 +92,35 @@ function makeChatCommandState(config: Config): ChatCommandState {
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -100,9 +137,9 @@ function makeManager() {
|
||||
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 +151,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 +172,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,20 +189,22 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
async function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
config?: Config;
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const config = options.config ?? createConfig();
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
chatCommandState: createChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
@@ -172,26 +213,325 @@ function makeRuntime(
|
||||
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(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
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(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
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("rejects manual compact when compaction is disabled", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
config.compaction = { enabled: false };
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"compaction is off",
|
||||
);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries compacted working context across mode-switch restarts", async () => {
|
||||
const firstSessionId = "sess-mode-before";
|
||||
const secondSessionId = "sess-mode-after";
|
||||
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(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
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,
|
||||
initialCompactionState: expect.objectContaining({
|
||||
source_message_count: messages.length,
|
||||
messages: [summaryMessage, tailMessage],
|
||||
system_prompt: "compacted system",
|
||||
}),
|
||||
});
|
||||
expect(restartInput.initialCompactionState).not.toHaveProperty(
|
||||
"conversation_id",
|
||||
);
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
|
||||
});
|
||||
|
||||
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(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
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();
|
||||
@@ -202,7 +542,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();
|
||||
|
||||
@@ -212,7 +552,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
@@ -224,7 +564,9 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: { session_id: "session-restarted" },
|
||||
manifest: createManifest("session-restarted"),
|
||||
manifestPath: "/tmp/session-restarted.json",
|
||||
messagesPath: "/tmp/session-restarted.messages.json",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -249,13 +591,13 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
createRuntimeHooksMock.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = makeRuntime(manager, {
|
||||
const runtime = await makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
@@ -307,14 +649,51 @@ 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(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
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",
|
||||
@@ -322,16 +701,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,
|
||||
@@ -348,8 +725,46 @@ 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(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
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();
|
||||
@@ -371,7 +786,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({
|
||||
@@ -404,7 +819,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
new SessionNotFoundError("session-1"),
|
||||
);
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
@@ -422,7 +837,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
|
||||
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
|
||||
const manager = makeManager();
|
||||
let runtime!: ReturnType<typeof makeRuntime>;
|
||||
let runtime!: Awaited<ReturnType<typeof makeRuntime>>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
@@ -432,7 +847,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = makeRuntime(manager);
|
||||
runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
@@ -450,7 +865,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
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -116,6 +119,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;
|
||||
|
||||
@@ -205,6 +209,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();
|
||||
@@ -214,6 +219,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -309,6 +315,25 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
};
|
||||
|
||||
const readCompactionState = async (
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await manager.readSessionCompactionState(sessionId);
|
||||
} catch (error) {
|
||||
input.config.logger?.log?.("Failed to read session compaction state", {
|
||||
sessionId,
|
||||
error,
|
||||
severity: "warn",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (
|
||||
error: unknown,
|
||||
): Promise<MissingSessionRecovery> => {
|
||||
@@ -343,6 +368,15 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const readCurrentCompactionState = async (): Promise<
|
||||
SessionCompactionState | undefined
|
||||
> => {
|
||||
if (!activeSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return await readCompactionState(activeSessionId);
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -377,48 +411,69 @@ export function createInteractiveSessionRuntime(input: {
|
||||
});
|
||||
};
|
||||
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
// Publish the restart as the in-flight startup. Teardown leaves a window
|
||||
// with no active session, and without this barrier a concurrent
|
||||
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
|
||||
// reads that window as "no session" and boots an empty session that then
|
||||
// races the restarted one for the active slot.
|
||||
const restart = (async () => {
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(
|
||||
messages,
|
||||
sessionMetadata,
|
||||
initialCompactionState,
|
||||
);
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
startupPromise = restart;
|
||||
try {
|
||||
await restart;
|
||||
} finally {
|
||||
// Restore the pre-restart steady state (startupPromise unset) so a
|
||||
// failed restart stays retryable by the next ensureReady(). A newer
|
||||
// startup that already replaced the barrier is left alone.
|
||||
if (startupPromise === restart) {
|
||||
startupPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
const [{ messages, status }, compactionState] = await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
]);
|
||||
if (status !== "read") {
|
||||
// If reading recovered a missing hub session, the current messages are
|
||||
// already in the replacement session. If the read is stale, another async
|
||||
// operation changed the active session while this read was in flight.
|
||||
return;
|
||||
}
|
||||
await restartWithMessages(messages);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await restartWithMessages(
|
||||
messages,
|
||||
undefined,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
@@ -532,6 +587,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Cannot fork an empty session.");
|
||||
}
|
||||
const compactionState = await readCompactionState(forkedFromSessionId);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await manager.stop(forkedFromSessionId);
|
||||
const forkMetadata = buildForkSessionMetadata({
|
||||
forkedFromSessionId,
|
||||
@@ -539,7 +598,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
sourceSession: sessionRecord,
|
||||
messages,
|
||||
});
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
await startFreshSession(
|
||||
messages,
|
||||
forkMetadata,
|
||||
projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
};
|
||||
|
||||
@@ -561,9 +630,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
if (!sessionManager) {
|
||||
if (input.config.compaction?.enabled === false) {
|
||||
throw new Error(
|
||||
"Cannot compact because compaction is off for this session.",
|
||||
);
|
||||
}
|
||||
const manager = sessionManager;
|
||||
const sourceSessionId = activeSessionId;
|
||||
if (!manager || !sourceSessionId) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
@@ -577,12 +654,28 @@ export function createInteractiveSessionRuntime(input: {
|
||||
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,
|
||||
@@ -590,10 +683,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,
|
||||
};
|
||||
};
|
||||
@@ -683,6 +790,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", () => {
|
||||
|
||||
@@ -90,6 +90,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)}; saved 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)}.`;
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"dependencies": {
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
@@ -625,7 +625,7 @@
|
||||
},
|
||||
"sdk/packages/core": {
|
||||
"name": "@cline/core",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"dependencies": {
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
@@ -663,7 +663,7 @@
|
||||
},
|
||||
"sdk/packages/llms": {
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.89",
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
@@ -697,14 +697,14 @@
|
||||
},
|
||||
"sdk/packages/sdk": {
|
||||
"name": "@cline/sdk",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"sdk/packages/shared": {
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"jsonrepair": "^3.13.2",
|
||||
@@ -1775,7 +1775,7 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="],
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.6.1", "", { "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
@@ -3275,7 +3275,7 @@
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
@@ -4901,7 +4901,7 @@
|
||||
|
||||
"ts-poet": ["ts-poet@6.12.0", "", { "dependencies": { "dprint-node": "^1.0.8" } }, "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA=="],
|
||||
|
||||
"ts-proto": ["ts-proto@2.11.9", "", { "dependencies": { "@bufbuild/protobuf": "^2.10.2", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" }, "bin": { "protoc-gen-ts_proto": "protoc-gen-ts_proto" } }, "sha512-rWmxXkEXV4qHc0xHteHMZ8i2V8KjUwloQ4MLb5dZwTBb0/9rtfnaFJO+4vm7irWmlV1gtkShT8O1zlY9B6lxcA=="],
|
||||
"ts-proto": ["ts-proto@2.11.10", "", { "dependencies": { "@bufbuild/protobuf": "^2.10.2", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" }, "bin": { "protoc-gen-ts_proto": "protoc-gen-ts_proto" } }, "sha512-7mvz2RbOZc0J/+x8biIcVHJ0nx7xjH79vtoEnkpKT5l8yFF5ERe03dee2W3CbB5vb1QgnbQXGzeBGGryJ+Q9EA=="],
|
||||
|
||||
"ts-proto-descriptors": ["ts-proto-descriptors@2.1.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA=="],
|
||||
|
||||
@@ -5697,8 +5697,6 @@
|
||||
|
||||
"@streamdown/code/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
|
||||
|
||||
"@tailwindcss/node/enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
@@ -6025,8 +6023,6 @@
|
||||
|
||||
"proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"puppeteer-core/@puppeteer/browsers": ["@puppeteer/browsers@2.6.1", "", { "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg=="],
|
||||
|
||||
"radix-ui/@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="],
|
||||
@@ -6219,6 +6215,8 @@
|
||||
|
||||
"unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
@@ -6995,6 +6993,28 @@
|
||||
|
||||
"unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.6", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"webview-ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"webview-ui/@vitejs/plugin-react-swc/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
@@ -7241,6 +7261,10 @@
|
||||
|
||||
"test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
|
||||
|
||||
"webview-ui/vitest/@vitest/utils/loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"webview-ui/vitest/chai/check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.58
|
||||
|
||||
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected
|
||||
|
||||
## 0.0.57
|
||||
|
||||
- Models in the live catalog that don't report a context window now default to a 128K input-token limit (up from 4,096), so under-specified models get a usable context budget
|
||||
|
||||
@@ -206,6 +206,38 @@ new Agent({
|
||||
For richer, host-side hook orchestration (15-stage `HookEngine`,
|
||||
subprocess-backed hooks, MCP extensions), use `@cline/core`.
|
||||
|
||||
### Preparing Requests with `prepareTurn`
|
||||
|
||||
`prepareTurn` runs before messages are sent to the provider. It can rewrite the
|
||||
messages or system prompt for the next request:
|
||||
|
||||
```text
|
||||
saved transcript
|
||||
|
|
||||
| turn preparation
|
||||
v
|
||||
prepareTurn
|
||||
|
|
||||
v
|
||||
prepared provider request
|
||||
```
|
||||
|
||||
Returned messages affect only the provider request for the current model call.
|
||||
They do not replace saved history and are not returned from
|
||||
`AgentRunResult.messages`.
|
||||
|
||||
```text
|
||||
prepareTurn returns prepared messages
|
||||
|
|
||||
+--> provider request: yes
|
||||
+--> saved transcript: no
|
||||
+--> AgentRunResult.messages: no
|
||||
```
|
||||
|
||||
This is intentionally different from changing saved history. Hosts that need
|
||||
durable redaction, normalization, or policy filtering must apply that change
|
||||
before a message enters the transcript.
|
||||
|
||||
### Plugins
|
||||
|
||||
Plugins can contribute tools and hooks at setup time:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -207,7 +207,7 @@ describe("AgentRuntime", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("injects pending user messages before prepareTurn rewrites the transcript", async () => {
|
||||
it("injects pending user messages before prepareTurn projects the provider request", async () => {
|
||||
const consumePendingUserMessage = vi.fn(() => "steer before prepare");
|
||||
const prepareTurn = vi.fn(
|
||||
(context: { messages: readonly AgentMessage[] }) => ({
|
||||
@@ -261,7 +261,7 @@ describe("AgentRuntime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lets prepareTurn compact tool results after pending user input is added", async () => {
|
||||
it("lets prepareTurn project tool results after pending user input is added", async () => {
|
||||
const consumePendingUserMessage = vi.fn(() => "latest steering");
|
||||
const hugeToolOutput = "x".repeat(100_000);
|
||||
const prepareTurn = vi.fn(
|
||||
@@ -1180,11 +1180,11 @@ describe("AgentRuntime", () => {
|
||||
expect(model.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs prepareTurn before beforeModel and persists rewritten messages", async () => {
|
||||
const compactedMessage: AgentMessage = {
|
||||
id: "msg_compacted",
|
||||
it("projects the provider request without overwriting canonical messages", async () => {
|
||||
const projectedMessage: AgentMessage = {
|
||||
id: "msg_projected",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "compacted context" }],
|
||||
content: [{ type: "text", text: "projected context" }],
|
||||
createdAt: 1,
|
||||
};
|
||||
const notices: string[] = [];
|
||||
@@ -1197,19 +1197,19 @@ describe("AgentRuntime", () => {
|
||||
reason: "auto_compaction",
|
||||
});
|
||||
return {
|
||||
messages: [compactedMessage],
|
||||
systemPrompt: "compacted system",
|
||||
messages: [projectedMessage],
|
||||
systemPrompt: "projected system",
|
||||
};
|
||||
});
|
||||
const beforeModel = vi.fn(({ request }) => {
|
||||
expect(request.systemPrompt).toBe("compacted system");
|
||||
expect(request.messages).toEqual([compactedMessage]);
|
||||
expect(request.systemPrompt).toBe("projected system");
|
||||
expect(request.messages).toEqual([projectedMessage]);
|
||||
return undefined;
|
||||
});
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
expect(request.systemPrompt).toBe("compacted system");
|
||||
expect(request.messages).toEqual([compactedMessage]);
|
||||
expect(request.systemPrompt).toBe("projected system");
|
||||
expect(request.messages).toEqual([projectedMessage]);
|
||||
return [
|
||||
{ type: "text-delta", text: "done" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
@@ -1233,8 +1233,13 @@ describe("AgentRuntime", () => {
|
||||
expect(prepareTurn).toHaveBeenCalledTimes(1);
|
||||
expect(beforeModel).toHaveBeenCalledTimes(1);
|
||||
expect(notices).toEqual(["auto-compacting"]);
|
||||
expect(result.messages[0]).toEqual(compactedMessage);
|
||||
expect(model.requests[0]?.messages).toEqual([projectedMessage]);
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "large context" }],
|
||||
});
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages).not.toContainEqual(projectedMessage);
|
||||
expect(model.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1340,16 +1345,16 @@ describe("AgentRuntime", () => {
|
||||
});
|
||||
|
||||
it("preserves the existing system prompt when prepareTurn returns only messages", async () => {
|
||||
const compactedMessage: AgentMessage = {
|
||||
id: "msg_compacted",
|
||||
const projectedMessage: AgentMessage = {
|
||||
id: "msg_projected",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "compacted context" }],
|
||||
content: [{ type: "text", text: "projected context" }],
|
||||
createdAt: 1,
|
||||
};
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
expect(request.systemPrompt).toBe("original system");
|
||||
expect(request.messages).toEqual([compactedMessage]);
|
||||
expect(request.messages).toEqual([projectedMessage]);
|
||||
return [
|
||||
{ type: "text-delta", text: "done" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
@@ -1359,7 +1364,7 @@ describe("AgentRuntime", () => {
|
||||
const runtime = new AgentRuntime({
|
||||
model,
|
||||
systemPrompt: "original system",
|
||||
prepareTurn: () => ({ messages: [compactedMessage] }),
|
||||
prepareTurn: () => ({ messages: [projectedMessage] }),
|
||||
});
|
||||
|
||||
await runtime.run("large context");
|
||||
|
||||
@@ -797,8 +797,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]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1079,7 +1086,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) {
|
||||
@@ -1088,14 +1094,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);
|
||||
@@ -1104,7 +1110,7 @@ export class AgentRuntime {
|
||||
snapshot: this.snapshot(),
|
||||
message,
|
||||
});
|
||||
return true;
|
||||
return message;
|
||||
}
|
||||
|
||||
private async updateUsage(usage: Partial<AgentUsage>): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -491,6 +491,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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
BlockBudgetClass,
|
||||
BudgetAction,
|
||||
BudgetActionKind,
|
||||
BudgetActionReason,
|
||||
BudgetPath,
|
||||
BudgetPolicyIntent,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
ContentBlockBudgetClassification,
|
||||
LiveTailHandling,
|
||||
} from "./types";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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";
|
||||
path: Required<BudgetPath>;
|
||||
reason: Exclude<BudgetActionReason, "protected_live_tail">;
|
||||
})
|
||||
| (BaseBudgetAction & {
|
||||
kind: "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 {
|
||||
messages: MessageWithMetadata[];
|
||||
actions: BudgetAction[];
|
||||
liveTailHandling: LiveTailHandling;
|
||||
estimatedTokens: number;
|
||||
warnings: BudgetProjectionWarning[];
|
||||
}
|
||||
|
||||
export interface ContentBlockBudgetClassification {
|
||||
block: ContentBlock;
|
||||
budgetClass: BlockBudgetClass;
|
||||
canStringTruncate: boolean;
|
||||
canDropWholeBlock: boolean;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import type { CoreCompactionContext } from "../../types/config";
|
||||
import { runBasicCompaction } from "./basic-compaction";
|
||||
import { createContextCompactionPrepareTurn } from "./compaction";
|
||||
import {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
createContextCompactionPrepareTurn,
|
||||
} from "./compaction";
|
||||
import {
|
||||
createTokenEstimator,
|
||||
resolveSummarizerConfig,
|
||||
@@ -2273,4 +2277,49 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
|
||||
expect(secondResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps stale sidecar state when replacement compaction returns no result", async () => {
|
||||
const originalMessages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "original" },
|
||||
];
|
||||
const existingState = createSessionCompactionState({
|
||||
sourceMessages: originalMessages,
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const compact = vi.fn().mockResolvedValue(undefined);
|
||||
const saveState = vi.fn();
|
||||
const prepareTurn = createCompactionStateAwarePrepareTurn({
|
||||
compact,
|
||||
getState: () => existingState,
|
||||
saveState,
|
||||
});
|
||||
const currentMessages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "edited original" },
|
||||
{ role: "assistant", content: "tail" },
|
||||
];
|
||||
|
||||
const result = await prepareTurn({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
messages: currentMessages,
|
||||
apiMessages: currentMessages,
|
||||
model: {
|
||||
id: "mock-model",
|
||||
provider: "anthropic",
|
||||
info: { id: "mock-model", maxInputTokens: 100_000 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(compact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messages: currentMessages }),
|
||||
);
|
||||
expect(saveState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
captureCompactionSkipped,
|
||||
type TelemetryCompactionStrategy,
|
||||
} from "../../services/telemetry/core-events";
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "../../session/models/session-compaction";
|
||||
import type {
|
||||
CoreCompactionConfig,
|
||||
CoreCompactionContext,
|
||||
@@ -44,6 +49,10 @@ export interface ContextPipelinePrepareTurnResult {
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export type ContextPipelinePrepareTurn = (
|
||||
context: ContextPipelinePrepareTurnInput,
|
||||
) => Promise<ContextPipelinePrepareTurnResult | undefined>;
|
||||
|
||||
type EstimateMessageTokens = ReturnType<typeof createTokenEstimator>;
|
||||
|
||||
type BuiltinCompactionStrategyOptions = {
|
||||
@@ -508,3 +517,62 @@ export function createContextCompactionPrepareTurn(
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompactionStateAwarePrepareTurn(input: {
|
||||
compact?: ContextPipelinePrepareTurn;
|
||||
getState?: () => SessionCompactionState | undefined;
|
||||
saveState?: (state: SessionCompactionState) => 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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1428,6 +1428,89 @@ describe("default read_files tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("folds orphan range entries into the preceding file entry", async () => {
|
||||
const execute = vi.fn(
|
||||
async (request: { path: string }) => `content:${request.path}`,
|
||||
);
|
||||
const tool = createReadFilesTool(execute);
|
||||
|
||||
await tool.execute(
|
||||
{
|
||||
files: [
|
||||
{ path: "/tmp/example.ips" },
|
||||
{ start_line: 45, end_line: 100 },
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
await tool.execute(
|
||||
{ paths: ["/tmp/a.ts", { end_line: 4 }, "/tmp/b.ts"] } as never,
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 2,
|
||||
},
|
||||
);
|
||||
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ path: "/tmp/example.ips", start_line: 45, end_line: 100 },
|
||||
expect.objectContaining({ iteration: 1 }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ path: "/tmp/a.ts", end_line: 4 },
|
||||
expect.objectContaining({ iteration: 2 }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
{ path: "/tmp/b.ts" },
|
||||
expect.objectContaining({ iteration: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects orphan range entries that cannot be attached to a file entry", async () => {
|
||||
const execute = vi.fn(async () => "should not run");
|
||||
const tool = createReadFilesTool(execute);
|
||||
|
||||
// Leading orphan range: no preceding file entry to fold into.
|
||||
await expect(
|
||||
tool.execute(
|
||||
{
|
||||
files: [{ start_line: 1, end_line: 2 }, { path: "/tmp/a.ts" }],
|
||||
} as never,
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Preceding entry already has its own range: keep the conflict visible.
|
||||
await expect(
|
||||
tool.execute(
|
||||
{
|
||||
files: [
|
||||
{ path: "/tmp/a.ts", start_line: 1 },
|
||||
{ start_line: 4, end_line: 8 },
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 2,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid union inputs before calling the executor", async () => {
|
||||
const execute = vi.fn(async () => "should not run");
|
||||
const tool = createReadFilesTool(execute);
|
||||
@@ -1609,7 +1692,7 @@ describe("zod schema conversion", () => {
|
||||
required: ["path"],
|
||||
},
|
||||
description:
|
||||
"Array of file read requests. Omit start_line/end_line or set them to null to read from the start; provide integers to return only that inclusive one-based line range. Reads are capped, so page through long files with start_line/end_line. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
"Array of file read requests; each element is one file and must include path. Omit start_line/end_line or set them to null to read from the start; provide integers on the same object as the path to return only that inclusive one-based line range — never emit a range as its own array element. Reads are capped, so page through long files with start_line/end_line. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
});
|
||||
expect(inputSchema.required).toEqual(["files"]);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MAX_SEARCH_OUTPUT_CHARS,
|
||||
} from "./executors/output-limits";
|
||||
import {
|
||||
coalesceOrphanReadRanges,
|
||||
formatError,
|
||||
formatReadFileQuery,
|
||||
formatRunCommandQueryPreview,
|
||||
@@ -246,9 +247,9 @@ export function createReadFilesTool(
|
||||
return createTool<ReadFilesInput, ToolOperationResult[]>({
|
||||
name: "read_files",
|
||||
description:
|
||||
"Read the content of text or image files at the provided absolute paths, or return only an inclusive one-based line range when start_line/end_line are provided. " +
|
||||
"Read the content of text or image files at the provided absolute paths, or return only an inclusive one-based line range when start_line/end_line are provided on the same file entry as its path. " +
|
||||
"When you already know multiple files you need, read them together in one call, and call this tool in the same response as other independent tool calls. " +
|
||||
`Each read returns at most ${MAX_READ_LINES} lines / ~${Math.round(MAX_READ_OUTPUT_CHARS / 1024)}k characters; longer files report their total line count, page through them with start_line/end_line. ` +
|
||||
`Each read returns at most ${MAX_READ_LINES} lines / ~${Math.round(MAX_READ_OUTPUT_CHARS / 1024)}k characters; longer files report their total line count, page through them with start_line/end_line on that file's entry. ` +
|
||||
"Binary files that are not image and large files are not supported. " +
|
||||
"Returns file contents or error messages for each path. ",
|
||||
inputSchema: zodToJsonSchema(ReadFilesInputSchema),
|
||||
@@ -256,7 +257,10 @@ export function createReadFilesTool(
|
||||
retryable: true,
|
||||
maxRetries: 1,
|
||||
execute: async (input, context) => {
|
||||
const validate = validateWithZod(ReadFilesInputUnionSchema, input);
|
||||
const validate = validateWithZod(
|
||||
ReadFilesInputUnionSchema,
|
||||
coalesceOrphanReadRanges(input),
|
||||
);
|
||||
let requests: ReadFileRequest[];
|
||||
if (typeof validate === "string") {
|
||||
requests = [{ path: validate }];
|
||||
|
||||
@@ -77,6 +77,63 @@ export function getReadFileRangeError(request: ReadFileRequest): string | null {
|
||||
return `start_line must be less than or equal to end_line (received start_line: ${start_line}, end_line: ${end_line})`;
|
||||
}
|
||||
|
||||
const READ_RANGE_KEYS = new Set(["start_line", "end_line"]);
|
||||
|
||||
function isOrphanReadRangeEntry(
|
||||
value: unknown,
|
||||
): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
return keys.length > 0 && keys.every((key) => READ_RANGE_KEYS.has(key));
|
||||
}
|
||||
|
||||
function coalesceOrphanReadRangeEntries(entries: unknown[]): unknown[] {
|
||||
const coalesced: unknown[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isOrphanReadRangeEntry(entry)) {
|
||||
const previous = coalesced[coalesced.length - 1];
|
||||
if (typeof previous === "string") {
|
||||
coalesced[coalesced.length - 1] = { path: previous, ...entry };
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
previous !== null &&
|
||||
typeof previous === "object" &&
|
||||
!Array.isArray(previous) &&
|
||||
"path" in previous &&
|
||||
Object.keys(entry).every((key) => !(key in previous))
|
||||
) {
|
||||
coalesced[coalesced.length - 1] = { ...previous, ...entry };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
coalesced.push(entry);
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some models emit a file's line range as a separate array element instead of
|
||||
* placing start_line/end_line on the same object as its path. Fold such
|
||||
* orphan range entries into the preceding file entry before validation.
|
||||
*/
|
||||
export function coalesceOrphanReadRanges(input: unknown): unknown {
|
||||
if (Array.isArray(input)) {
|
||||
return coalesceOrphanReadRangeEntries(input);
|
||||
}
|
||||
if (input !== null && typeof input === "object") {
|
||||
for (const key of ["files", "paths"] as const) {
|
||||
const value = (input as Record<string, unknown>)[key];
|
||||
if (Array.isArray(value)) {
|
||||
return { ...input, [key]: coalesceOrphanReadRangeEntries(value) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function normalizeRunCommandsInput(
|
||||
input: unknown,
|
||||
): Array<string | StructuredCommandInput> {
|
||||
|
||||
@@ -46,7 +46,7 @@ export const ReadFileRequestSchema = z
|
||||
end_line: ReadFileLineRangeSchema.shape.end_line,
|
||||
})
|
||||
.describe(
|
||||
"A file read request with optional inclusive one-based line bounds",
|
||||
"A file read request with optional inclusive one-based line bounds. Always include path; start_line/end_line must be on the same object as the path they apply to, never in a separate array element",
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ export const ReadFilesInputSchema = z.object({
|
||||
files: z
|
||||
.array(ReadFileRequestSchema)
|
||||
.describe(
|
||||
"Array of file read requests. Omit start_line/end_line or set them to null to read from the start; provide integers to return only that inclusive one-based line range. Reads are capped, so page through long files with start_line/end_line. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
"Array of file read requests; each element is one file and must include path. Omit start_line/end_line or set them to null to read from the start; provide integers on the same object as the path to return only that inclusive one-based line range — never emit a range as its own array element. Reads are capped, so page through long files with start_line/end_line. Prefer this tool over running terminal command to get file content for better performance and reliability.",
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentToolContext, HubEventEnvelope } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import { SessionSource } from "../../types/common";
|
||||
|
||||
const commandMock = vi.hoisted(() => vi.fn());
|
||||
@@ -1410,6 +1411,69 @@ describe("HubRuntimeHost", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records rejected compaction state updates as handled errors", async () => {
|
||||
const telemetry = { capture: vi.fn() };
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "sess-1",
|
||||
});
|
||||
commandMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "session_wrong_client",
|
||||
message: "Session sess-1 is owned by other-client",
|
||||
},
|
||||
});
|
||||
|
||||
const { HubRuntimeHost } = await import("./hub-runtime-host");
|
||||
const host = new HubRuntimeHost({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
telemetry: telemetry as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
host.updateSessionCompactionState(" sess-1 ", state),
|
||||
).resolves.toEqual({ updated: false });
|
||||
expect(telemetry.capture).toHaveBeenCalledWith({
|
||||
event: "sdk.error",
|
||||
properties: expect.objectContaining({
|
||||
component: "core",
|
||||
operation: "hub.runtime_host.update_session_compaction_state",
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
command: "session.compaction.update",
|
||||
sessionId: "sess-1",
|
||||
errorCode: "session_wrong_client",
|
||||
error_message: "Session sess-1 is owned by other-client",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("treats stale compaction state updates as non-error no-ops", async () => {
|
||||
const telemetry = { capture: vi.fn() };
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "sess-1",
|
||||
});
|
||||
commandMock.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { updated: false },
|
||||
});
|
||||
|
||||
const { HubRuntimeHost } = await import("./hub-runtime-host");
|
||||
const host = new HubRuntimeHost({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
telemetry: telemetry as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
host.updateSessionCompactionState("sess-1", state),
|
||||
).resolves.toEqual({ updated: false });
|
||||
expect(telemetry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the hub rejects settings list", async () => {
|
||||
commandMock.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -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,54 @@ 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,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
captureSdkError(this.telemetry, {
|
||||
component: "core",
|
||||
operation: "hub.runtime_host.update_session_compaction_state",
|
||||
error: new Error(
|
||||
hubReplyErrorMessage(reply, "session.compaction.update"),
|
||||
),
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
context: {
|
||||
command: "session.compaction.update",
|
||||
sessionId: target,
|
||||
errorCode: reply.error?.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
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 {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
requestToolApproval,
|
||||
} from "./handlers/approval-handlers";
|
||||
import {
|
||||
ensureSessionParticipant,
|
||||
ensureSessionState,
|
||||
type HubTransportContext,
|
||||
} from "./handlers/context";
|
||||
@@ -797,6 +799,383 @@ 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("does not grant compaction sidecar ownership from session attach", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
|
||||
const updateSessionCompactionState = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true });
|
||||
const transport = createTransport({
|
||||
sessionHost: {
|
||||
readSessionCompactionState,
|
||||
updateSessionCompactionState,
|
||||
},
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
expect(ctx.sessionState.has("session-1")).toBe(false);
|
||||
|
||||
const attachReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-attach",
|
||||
command: "session.attach",
|
||||
clientId: "viewer-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(attachReply).toMatchObject({ ok: true });
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.createdByClientId,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.participants.has("viewer-client"),
|
||||
).toBe(true);
|
||||
|
||||
const getReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-get",
|
||||
command: "session.compaction.get",
|
||||
clientId: "viewer-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
const updateReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-update",
|
||||
command: "session.compaction.update",
|
||||
clientId: "viewer-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state },
|
||||
});
|
||||
|
||||
expect(getReply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_wrong_client" },
|
||||
});
|
||||
expect(updateReply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_wrong_client" },
|
||||
});
|
||||
expect(readSessionCompactionState).not.toHaveBeenCalled();
|
||||
expect(updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a creator to claim ownerless compaction sidecar ownership", 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);
|
||||
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
|
||||
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.createdByClientId,
|
||||
).toBeUndefined();
|
||||
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
expect(ctx.sessionState.get("session-1")?.createdByClientId).toBe(
|
||||
"owner-client",
|
||||
);
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.participants.has("viewer-client"),
|
||||
).toBe(true);
|
||||
|
||||
const getReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-get",
|
||||
command: "session.compaction.get",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(getReply).toMatchObject({
|
||||
ok: true,
|
||||
payload: { state },
|
||||
});
|
||||
expect(readSessionCompactionState).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
it("clears compaction sidecar ownership when the owner detaches", async () => {
|
||||
const readSessionCompactionState = vi.fn();
|
||||
const transport = createTransport({
|
||||
sessionHost: { readSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
|
||||
|
||||
const detachReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-detach",
|
||||
command: "session.detach",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(detachReply).toMatchObject({ ok: true });
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.participants.has("viewer-client"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.createdByClientId,
|
||||
).toBeUndefined();
|
||||
|
||||
const getReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-get",
|
||||
command: "session.compaction.get",
|
||||
clientId: "viewer-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(getReply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_wrong_client" },
|
||||
});
|
||||
expect(readSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears compaction sidecar ownership when the owner unregisters", async () => {
|
||||
const readSessionCompactionState = vi.fn();
|
||||
const transport = createTransport({
|
||||
sessionHost: { readSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
|
||||
|
||||
const unregisterReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-unregister",
|
||||
command: "client.unregister",
|
||||
clientId: "owner-client",
|
||||
});
|
||||
|
||||
expect(unregisterReply).toMatchObject({ ok: true });
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.participants.has("viewer-client"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ctx.sessionState.get("session-1")?.createdByClientId,
|
||||
).toBeUndefined();
|
||||
|
||||
const getReply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-get",
|
||||
command: "session.compaction.get",
|
||||
clientId: "viewer-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(getReply).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: true,
|
||||
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({
|
||||
|
||||
@@ -183,6 +183,9 @@ export function ensureSessionState(
|
||||
if (options.interactive !== undefined) {
|
||||
existing.interactive = options.interactive;
|
||||
}
|
||||
if (role === "creator" && !existing.createdByClientId) {
|
||||
existing.createdByClientId = clientId;
|
||||
}
|
||||
if (!existing.participants.has(clientId)) {
|
||||
existing.participants.set(clientId, {
|
||||
clientId,
|
||||
@@ -209,3 +212,41 @@ export function ensureSessionState(
|
||||
ctx.sessionState.set(sessionId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function ensureSessionParticipant(
|
||||
ctx: HubTransportContext,
|
||||
sessionId: string,
|
||||
clientId: string,
|
||||
role: SessionParticipant["role"],
|
||||
options: { interactive?: boolean } = {},
|
||||
): HubSessionState {
|
||||
const existing = ctx.sessionState.get(sessionId);
|
||||
if (existing) {
|
||||
if (options.interactive !== undefined) {
|
||||
existing.interactive = options.interactive;
|
||||
}
|
||||
if (!existing.participants.has(clientId)) {
|
||||
existing.participants.set(clientId, {
|
||||
clientId,
|
||||
attachedAt: Date.now(),
|
||||
role,
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const state: HubSessionState = {
|
||||
interactive: options.interactive ?? true,
|
||||
participants: new Map([
|
||||
[
|
||||
clientId,
|
||||
{
|
||||
clientId,
|
||||
attachedAt: Date.now(),
|
||||
role,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
ctx.sessionState.set(sessionId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -19,6 +20,7 @@ import { toHubSessionRecord } from "../hub-session-records";
|
||||
import { cancelPendingCapabilityRequests } from "./capability-handlers";
|
||||
import {
|
||||
asPlainRecord,
|
||||
ensureSessionParticipant,
|
||||
ensureSessionState,
|
||||
errorReply,
|
||||
extractSessionId,
|
||||
@@ -30,18 +32,50 @@ import {
|
||||
|
||||
const CAPABILITY_OWNER_METADATA_KEY = "hubCapabilityOwnerClientId";
|
||||
|
||||
function setCapabilityOwner(
|
||||
metadata: Record<string, unknown>,
|
||||
clientId: string,
|
||||
): void {
|
||||
metadata[CAPABILITY_OWNER_METADATA_KEY] = clientId;
|
||||
function getCapabilityOwnerClientId(
|
||||
ctx: HubTransportContext,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
// Sidecar access follows the live hub owner, not persisted metadata clients
|
||||
// can replay or edit.
|
||||
return ctx.sessionState.get(sessionId)?.createdByClientId;
|
||||
}
|
||||
|
||||
function getCapabilityOwnerClientId(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const owner = metadata?.[CAPABILITY_OWNER_METADATA_KEY];
|
||||
return typeof owner === "string" && owner.trim() ? owner.trim() : undefined;
|
||||
function stripServerOwnedSessionMetadata(
|
||||
metadata: Record<string, JsonValue | undefined> | undefined,
|
||||
): Record<string, JsonValue | undefined> | undefined {
|
||||
// Clients may echo old records back through session.update; keep ownership
|
||||
// on the live hub state only.
|
||||
if (!metadata || !(CAPABILITY_OWNER_METADATA_KEY in metadata)) {
|
||||
return metadata;
|
||||
}
|
||||
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 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 +111,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 +161,6 @@ 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);
|
||||
}
|
||||
const requestedSessionId =
|
||||
typeof sessionConfig?.sessionId === "string"
|
||||
? sessionConfig.sessionId.trim()
|
||||
@@ -175,6 +209,7 @@ export async function handleSessionCreate(
|
||||
initialMessages: Array.isArray(payload.initialMessages)
|
||||
? (payload.initialMessages as never[])
|
||||
: undefined,
|
||||
initialCompactionState,
|
||||
localRuntime: {
|
||||
modelCatalogDefaults: {
|
||||
loadLatestOnInit: true,
|
||||
@@ -352,6 +387,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 +418,6 @@ export async function handleSessionRestore(
|
||||
const clientContributions = parseHubClientContributions(
|
||||
runtimeOptions.clientContributions,
|
||||
);
|
||||
if (clientContributions.length > 0) {
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
}
|
||||
const requestedSessionId =
|
||||
typeof sessionConfig?.sessionId === "string"
|
||||
? sessionConfig.sessionId.trim()
|
||||
@@ -439,6 +474,7 @@ export async function handleSessionRestore(
|
||||
restoredCheckpointRunCount: checkpointRunCount,
|
||||
},
|
||||
initialMessages: context.initialMessages,
|
||||
initialCompactionState,
|
||||
localRuntime: {
|
||||
modelCatalogDefaults: {
|
||||
loadLatestOnInit: true,
|
||||
@@ -586,23 +622,29 @@ export async function handleSessionAttach(
|
||||
"session.attach requires a session id",
|
||||
);
|
||||
}
|
||||
ensureSessionState(
|
||||
const session = await readHubSessionRecord(ctx, sessionId);
|
||||
if (!session) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"session_not_found",
|
||||
`Unknown session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
ensureSessionParticipant(
|
||||
ctx,
|
||||
sessionId,
|
||||
envelope.clientId?.trim() || "hub-client",
|
||||
"participant",
|
||||
);
|
||||
const session = await readHubSessionRecord(ctx, sessionId);
|
||||
if (session) {
|
||||
ctx.publish(ctx.buildEvent("session.attached", { session }, sessionId));
|
||||
}
|
||||
return session
|
||||
? okReply(envelope, { session })
|
||||
: errorReply(
|
||||
envelope,
|
||||
"session_not_found",
|
||||
`Unknown session: ${sessionId}`,
|
||||
);
|
||||
const attachedSession = await readHubSessionRecord(ctx, sessionId);
|
||||
ctx.publish(
|
||||
ctx.buildEvent(
|
||||
"session.attached",
|
||||
{ session: attachedSession ?? session },
|
||||
sessionId,
|
||||
),
|
||||
);
|
||||
return okReply(envelope, { session: attachedSession ?? session });
|
||||
}
|
||||
|
||||
export async function handleSessionDetach(
|
||||
@@ -618,18 +660,11 @@ 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 state = ctx.sessionState.get(sessionId);
|
||||
if (state) {
|
||||
state.participants.delete(clientId);
|
||||
if (state.createdByClientId === clientId) {
|
||||
state.createdByClientId = ownerClientId;
|
||||
state.createdByClientId = undefined;
|
||||
}
|
||||
if (state.participants.size === 0) {
|
||||
ctx.sessionState.delete(sessionId);
|
||||
@@ -702,6 +737,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 +791,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 +820,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: true,
|
||||
payload: {
|
||||
updated: updated.updated,
|
||||
session: updatedSession ?? session,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionDelete(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
import { projectSessionEvent } from "./handlers/session-event-projector";
|
||||
import {
|
||||
handleSessionAttach,
|
||||
handleSessionCompactionGet,
|
||||
handleSessionCompactionUpdate,
|
||||
handleSessionCreate,
|
||||
handleSessionDelete,
|
||||
handleSessionDetach,
|
||||
@@ -361,10 +363,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":
|
||||
@@ -547,6 +553,9 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
private detachClientFromSessions(clientId: string): void {
|
||||
for (const [sessionId, state] of this.sessionState.entries()) {
|
||||
state.participants.delete(clientId);
|
||||
if (state.createdByClientId === clientId) {
|
||||
state.createdByClientId = undefined;
|
||||
}
|
||||
if (state.participants.size === 0) {
|
||||
this.sessionState.delete(sessionId);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { SessionAccumulatedUsage } from "../../runtime/host/runtime-host";
|
||||
import type { SessionRecord as LocalSessionRecord } from "../../types/sessions";
|
||||
|
||||
export type HubSessionState = {
|
||||
createdByClientId: string;
|
||||
createdByClientId?: string;
|
||||
interactive: boolean;
|
||||
participants: Map<string, SessionParticipant>;
|
||||
};
|
||||
|
||||
@@ -759,7 +759,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,
|
||||
@@ -879,6 +882,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,6 +14,7 @@ import type {
|
||||
import { setClineDir, setHomeDir } from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TelemetryService } from "../../services/telemetry/TelemetryService";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import type { SessionManifest } from "../../session/models/session-manifest";
|
||||
import { SessionSource } from "../../types/common";
|
||||
import type { CoreSessionConfig } from "../../types/config";
|
||||
@@ -4336,6 +4337,212 @@ describe("LocalRuntimeHost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("binds unowned initial compaction state to the started session", async () => {
|
||||
const sessionId = "sess-compaction-initial";
|
||||
const manifest = createManifest(sessionId);
|
||||
const initialMessages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "large source" },
|
||||
];
|
||||
const initialCompactionState = createSessionCompactionState({
|
||||
sourceMessages: initialMessages,
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest-compaction-initial.json",
|
||||
messagesPath: "/tmp/messages-compaction-initial.json",
|
||||
manifest,
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
persistSessionCompactionState: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const run = vi.fn().mockResolvedValue(createResult());
|
||||
const createAgent = vi.fn().mockReturnValue({
|
||||
run,
|
||||
continue: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
canStartRun: vi.fn().mockReturnValue(true),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue(sessionId),
|
||||
restore: vi.fn(),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getMessages: vi.fn().mockReturnValue(initialMessages),
|
||||
messages: initialMessages,
|
||||
});
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: {
|
||||
build: vi.fn().mockReturnValue({
|
||||
tools: [],
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
},
|
||||
createAgent: createAgent as never,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({
|
||||
sessionId,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
compact: vi.fn(),
|
||||
},
|
||||
}),
|
||||
initialMessages,
|
||||
initialCompactionState,
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sessionService.persistSessionCompactionState).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
expect.objectContaining({
|
||||
conversation_id: sessionId,
|
||||
messages: initialCompactionState.messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not project compaction state when compaction is disabled", async () => {
|
||||
const sessionId = "sess-compaction-disabled";
|
||||
const manifest = createManifest(sessionId);
|
||||
const initialMessages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "canonical source" },
|
||||
];
|
||||
const initialCompactionState = createSessionCompactionState({
|
||||
sourceMessages: initialMessages,
|
||||
compactedMessages: [{ role: "user", content: "projected summary" }],
|
||||
conversationId: sessionId,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest-compaction-disabled.json",
|
||||
messagesPath: "/tmp/messages-compaction-disabled.json",
|
||||
manifest,
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
persistSessionCompactionState: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const run = vi.fn().mockResolvedValue(createResult());
|
||||
const createAgent = vi.fn().mockReturnValue({
|
||||
run,
|
||||
continue: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
canStartRun: vi.fn().mockReturnValue(true),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue(sessionId),
|
||||
restore: vi.fn(),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getMessages: vi.fn().mockReturnValue(initialMessages),
|
||||
messages: initialMessages,
|
||||
});
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: {
|
||||
build: vi.fn().mockReturnValue({
|
||||
tools: [],
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
},
|
||||
createAgent: createAgent as never,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({
|
||||
sessionId,
|
||||
compaction: {
|
||||
enabled: false,
|
||||
strategy: "basic",
|
||||
},
|
||||
}),
|
||||
initialMessages,
|
||||
initialCompactionState,
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const prepareTurn = createAgent.mock.calls[0]?.[0]?.prepareTurn;
|
||||
expect(prepareTurn).toBeUndefined();
|
||||
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
manager.updateSessionCompactionState(sessionId, initialCompactionState),
|
||||
).resolves.toEqual({ updated: true });
|
||||
expect(createAgent.mock.calls[0]?.[0]?.prepareTurn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders equal-length compaction updates by parsed timestamp", async () => {
|
||||
const sessionId = "inactive-session";
|
||||
const tempCwd = mkdtempSync(join(tmpdir(), "compaction-stale-"));
|
||||
try {
|
||||
const messagesPath = join(tempCwd, "messages.json");
|
||||
const sourceMessages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "source" },
|
||||
];
|
||||
writeFileSync(messagesPath, JSON.stringify(sourceMessages), "utf8");
|
||||
const current = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [{ role: "user", content: "current" }],
|
||||
conversationId: sessionId,
|
||||
updatedAt: "2026-01-01T00:00:00.500Z",
|
||||
});
|
||||
const incoming = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [{ role: "user", content: "incoming" }],
|
||||
conversationId: sessionId,
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
listSessions: vi.fn().mockResolvedValue([
|
||||
{
|
||||
sessionId,
|
||||
provider: "mock-provider",
|
||||
model: "mock-model",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
status: "running",
|
||||
messagesPath,
|
||||
},
|
||||
]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(current),
|
||||
persistSessionCompactionState: vi.fn(),
|
||||
};
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.updateSessionCompactionState(sessionId, incoming),
|
||||
).resolves.toEqual({ updated: false });
|
||||
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("formats prompt in core and merges explicit + mention user files", async () => {
|
||||
const tempCwd = mkdtempSync(join(tmpdir(), "core-session-format-"));
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
@@ -171,6 +178,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 Date.parse(incoming.updated_at) < Date.parse(current.updated_at);
|
||||
}
|
||||
|
||||
export interface LocalRuntimeHostOptions {
|
||||
distinctId?: string;
|
||||
sessionService: SessionBackend;
|
||||
@@ -343,6 +363,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 &&
|
||||
@@ -357,8 +378,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({
|
||||
@@ -455,6 +482,65 @@ 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 rawInitialCompactionState =
|
||||
explicitInitialCompactionState ?? resumedCompactionState;
|
||||
const initialCompactionState =
|
||||
compact && rawInitialCompactionState
|
||||
? {
|
||||
...rawInitialCompactionState,
|
||||
conversation_id:
|
||||
rawInitialCompactionState.conversation_id?.trim() || sessionId,
|
||||
}
|
||||
: undefined;
|
||||
const prepareTurn = compact
|
||||
? 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const agentConfig = {
|
||||
sessionId,
|
||||
@@ -472,7 +558,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
systemPrompt: configWithProvider.systemPrompt,
|
||||
maxIterations: configWithProvider.maxIterations,
|
||||
execution: configWithProvider.execution,
|
||||
prepareTurn: createContextCompactionPrepareTurn(configWithProvider),
|
||||
prepareTurn,
|
||||
tools,
|
||||
hooks: bootstrap.hooks,
|
||||
extensions,
|
||||
@@ -616,6 +702,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
aborting: false,
|
||||
interactive: input.interactive === true,
|
||||
persistedMessages: initialMessages,
|
||||
compactionState: initialCompactionState,
|
||||
activeTeamRunIds: new Set<string>(),
|
||||
pendingTeamRunUpdates: [],
|
||||
teamRunWaiters: [],
|
||||
@@ -625,6 +712,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) {
|
||||
@@ -635,6 +741,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);
|
||||
}
|
||||
@@ -928,6 +1043,155 @@ 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 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(
|
||||
|
||||
@@ -564,7 +564,7 @@ describe("SessionRuntime message preparation", () => {
|
||||
]);
|
||||
const textParts = result?.messages?.flatMap((message) =>
|
||||
message.content.flatMap((part) =>
|
||||
part.type === "text" ? [part.text] : [],
|
||||
typeof part !== "string" && part.type === "text" ? [part.text] : [],
|
||||
),
|
||||
);
|
||||
expect(textParts).toEqual(["original", "builder-added"]);
|
||||
@@ -1426,7 +1426,10 @@ describe("SessionRuntime real AgentRuntime smoke", () => {
|
||||
expect(
|
||||
modelRequests[1]?.some((message) =>
|
||||
message.content.some(
|
||||
(part) => part.type === "text" && part.text === EMPTY_CONTENT_TEXT,
|
||||
(part) =>
|
||||
typeof part !== "string" &&
|
||||
part.type === "text" &&
|
||||
part.text === EMPTY_CONTENT_TEXT,
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
parseSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
} from "./session-compaction";
|
||||
|
||||
describe("session compaction state", () => {
|
||||
it("rejects fallback boundary keys when a message role contains the delimiter", () => {
|
||||
expect(() =>
|
||||
createSessionCompactionState({
|
||||
sourceMessages: [
|
||||
{
|
||||
role: "user:custom",
|
||||
content: "invalid role",
|
||||
} as never,
|
||||
],
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
).toThrow("Message role cannot contain ':'");
|
||||
});
|
||||
|
||||
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 after persisted source messages are reloaded from JSON", () => {
|
||||
const sourceMessages = [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant" as const,
|
||||
content: "answer",
|
||||
metadata: { b: 2, a: 1 },
|
||||
metrics: { inputTokens: 10, outputTokens: 5 },
|
||||
},
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const reloadedMessages = JSON.parse(JSON.stringify(sourceMessages));
|
||||
|
||||
expect(projectSessionCompactionState(state, reloadedMessages)).toEqual(
|
||||
compactedMessages,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects projection when canonical message metadata changed", () => {
|
||||
const sourceMessages = [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant" as const,
|
||||
content: "answer",
|
||||
metadata: { stable: true },
|
||||
},
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(
|
||||
projectSessionCompactionState(state, [
|
||||
{ ...sourceMessages[0], metadata: { stable: false } },
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
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("projects legacy sidecars when the boundary key matches", () => {
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "original detail" },
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const tail = { id: "u2", role: "user" as const, content: "tail" };
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
source_message_count: sourceMessages.length,
|
||||
source_last_message_key: "id:a1",
|
||||
messages: [{ id: "summary", role: "user" as const, content: "summary" }],
|
||||
});
|
||||
|
||||
expect(state).toBeDefined();
|
||||
if (!state) {
|
||||
throw new Error("expected parsed compaction state");
|
||||
}
|
||||
expect(
|
||||
projectSessionCompactionState(state, [...sourceMessages, tail]),
|
||||
).toEqual([{ id: "summary", role: "user", content: "summary" }, tail]);
|
||||
});
|
||||
|
||||
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,188 @@
|
||||
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(JSON.stringify(messages)) as MessageWithMetadata[];
|
||||
}
|
||||
|
||||
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 assertBoundaryRole(role: MessageWithMetadata["role"]): void {
|
||||
if (role.includes(":")) {
|
||||
throw new TypeError(
|
||||
"Message role cannot contain ':' in compaction boundary keys",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the persisted message shape in a fixed top-level field order. Nested
|
||||
// objects keep their persisted JSON order because transcript writes are append-only.
|
||||
function sourceMessageHashInput(message: MessageWithMetadata): unknown[] {
|
||||
const normalized = normalizeMessageForSourceHash(message);
|
||||
assertBoundaryRole(normalized.role);
|
||||
return [
|
||||
["role", normalized.role],
|
||||
["content", normalized.content],
|
||||
["id", normalized.id ?? null],
|
||||
["agent", normalized.agent ?? null],
|
||||
["sessionId", normalized.sessionId ?? null],
|
||||
["metadata", normalized.metadata ?? null],
|
||||
["modelInfo", normalized.modelInfo ?? null],
|
||||
["metrics", normalized.metrics ?? null],
|
||||
["ts", normalized.ts ?? null],
|
||||
];
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
assertBoundaryRole(normalized.role);
|
||||
if (typeof normalized.ts === "number" && Number.isFinite(normalized.ts)) {
|
||||
return `ts:${normalized.role}:${normalized.ts}`;
|
||||
}
|
||||
return `content:${normalized.role}:${JSON.stringify(normalized.content)}`;
|
||||
}
|
||||
|
||||
// These anchors are persisted in session sidecars. Changing the format is safe
|
||||
// for saved transcripts, but invalidates existing compaction sidecars.
|
||||
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(JSON.stringify(sourceMessageHashInput(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 {
|
||||
const hasEnoughSourceMessages =
|
||||
state.source_message_count <= sourceMessages.length;
|
||||
if (!hasEnoughSourceMessages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hasMatchingSourcePrefix =
|
||||
!!state.source_prefix_hash &&
|
||||
sourcePrefixHash(sourceMessages, state.source_message_count) ===
|
||||
state.source_prefix_hash;
|
||||
const boundary = sourceMessages[state.source_message_count - 1];
|
||||
const hasMatchingLegacyBoundary =
|
||||
!state.source_prefix_hash &&
|
||||
state.source_message_count > 0 &&
|
||||
!!state.source_last_message_key &&
|
||||
messageBoundaryKey(boundary) === state.source_last_message_key;
|
||||
const canProjectState = hasMatchingSourcePrefix || hasMatchingLegacyBoundary;
|
||||
if (!canProjectState) {
|
||||
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,160 @@ 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",
|
||||
});
|
||||
expect(
|
||||
JSON.parse(readFileSync(artifacts.manifestPath, "utf8")),
|
||||
).not.toHaveProperty("compaction_path");
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(false);
|
||||
|
||||
await service.persistSessionMessages(sessionId, sourceMessages);
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(artifacts.manifestPath, "utf8")),
|
||||
).toHaveProperty("compaction_path", artifacts.compactionPath);
|
||||
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);
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(true);
|
||||
await service.deleteSessionCompactionState(sessionId);
|
||||
|
||||
expect(existsSync(artifacts.messagesPath)).toBe(true);
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(false);
|
||||
expect(
|
||||
JSON.parse(readFileSync(artifacts.manifestPath, "utf8")),
|
||||
).not.toHaveProperty("compaction_path");
|
||||
await expect(
|
||||
service.readSessionCompactionState(sessionId),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds compaction path to old manifests only when sidecar is written", 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")),
|
||||
).toHaveProperty("compaction_path", artifacts.compactionPath);
|
||||
});
|
||||
|
||||
sqliteIt(
|
||||
"reconciles dead running sessions into failed manifests with terminal markers",
|
||||
async () => {
|
||||
@@ -550,4 +711,55 @@ describe("UnifiedSessionPersistenceService", () => {
|
||||
expect(existsSync(join(sessionsDir, sessionId))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
sqliteIt(
|
||||
"deletes a session when compaction sidecar cleanup fails",
|
||||
async () => {
|
||||
const dbDir = mkdtempSync(join(tmpdir(), "delete-sidecar-fail-db-"));
|
||||
const sessionsDir = mkdtempSync(
|
||||
join(tmpdir(), "delete-sidecar-fail-sessions-"),
|
||||
);
|
||||
tempDirs.push(dbDir, sessionsDir);
|
||||
|
||||
const store = new SqliteSessionStore({ sessionsDir: dbDir });
|
||||
stores.push(store);
|
||||
const service = new CoreSessionService(store, {
|
||||
sessionArtifactsDir: sessionsDir,
|
||||
});
|
||||
const sessionId = "sidecar-delete-fail-session";
|
||||
await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
prompt: "delete me",
|
||||
startedAt: "2026-04-10T19:00:00.000Z",
|
||||
});
|
||||
const manifestStore = (
|
||||
service as unknown as {
|
||||
manifestStore: {
|
||||
deleteSessionCompactionState: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).manifestStore;
|
||||
const deleteSidecar = vi
|
||||
.spyOn(manifestStore, "deleteSessionCompactionState")
|
||||
.mockRejectedValue(new Error("sidecar busy"));
|
||||
|
||||
const result = await service.deleteSession(sessionId);
|
||||
|
||||
expect(result).toEqual({ deleted: true });
|
||||
await expect(service.listSessions(10)).resolves.not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ sessionId })]),
|
||||
);
|
||||
expect(deleteSidecar).toHaveBeenCalledWith(sessionId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
@@ -172,7 +175,7 @@ export class UnifiedSessionPersistenceService {
|
||||
startedAt,
|
||||
);
|
||||
this.manifestStore.writeSessionManifest(manifestPath, manifest);
|
||||
return { manifestPath, messagesPath, manifest };
|
||||
return { manifestPath, messagesPath, compactionPath, manifest };
|
||||
}
|
||||
|
||||
async updateSessionStatus(
|
||||
@@ -320,6 +323,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 +567,7 @@ export class UnifiedSessionPersistenceService {
|
||||
children.map(async (child) => {
|
||||
await deleteCheckpointRefs(child.cwd, child.sessionId);
|
||||
unlinkIfExists(child.messagesPath);
|
||||
await this.deleteSessionCompactionStateIfExists(child.sessionId);
|
||||
unlinkIfExists(
|
||||
this.manifestStore.artifacts.sessionManifestPath(
|
||||
child.sessionId,
|
||||
@@ -561,6 +582,7 @@ export class UnifiedSessionPersistenceService {
|
||||
await deleteCheckpointRefs(row.cwd, id);
|
||||
|
||||
unlinkIfExists(row.messagesPath);
|
||||
await this.deleteSessionCompactionStateIfExists(id);
|
||||
unlinkIfExists(this.manifestStore.artifacts.sessionManifestPath(id, false));
|
||||
if (row.isSubagent) {
|
||||
this.manifestStore.artifacts.removeSessionDirIfEmpty(id);
|
||||
@@ -579,4 +601,12 @@ export class UnifiedSessionPersistenceService {
|
||||
}
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
private async deleteSessionCompactionStateIfExists(
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.manifestStore.deleteSessionCompactionState(sessionId);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, open, rename, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export 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, "wx");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { readFile, 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,10 +21,25 @@ import type {
|
||||
SessionPersistenceAdapter,
|
||||
StoredMessageWithMetadata,
|
||||
} from "../../types/session";
|
||||
import {
|
||||
parseSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
SessionCompactionStateSchema,
|
||||
} from "../models/session-compaction";
|
||||
import {
|
||||
type SessionManifest,
|
||||
SessionManifestSchema,
|
||||
} from "../models/session-manifest";
|
||||
import { writeFileAtomic } from "./atomic-file";
|
||||
|
||||
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 +151,66 @@ export class SessionManifestStore {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCompactionPath(sessionId: string): string {
|
||||
const { manifest } = this.readManifestFile(sessionId);
|
||||
return (
|
||||
manifest?.compaction_path?.trim() ||
|
||||
this.artifacts.sessionCompactionPath(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
private updateCompactionPath(sessionId: string, path: string | undefined): void {
|
||||
const manifestFile = this.readManifestFile(sessionId);
|
||||
if (!manifestFile.manifest) {
|
||||
return;
|
||||
}
|
||||
if (manifestFile.manifest.compaction_path === path) {
|
||||
return;
|
||||
}
|
||||
this.writeSessionManifest(manifestFile.path, {
|
||||
...manifestFile.manifest,
|
||||
compaction_path: path,
|
||||
});
|
||||
}
|
||||
|
||||
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`);
|
||||
this.updateCompactionPath(sessionId, path);
|
||||
}
|
||||
|
||||
async deleteSessionCompactionState(sessionId: string): Promise<void> {
|
||||
await rm(this.resolveCompactionPath(sessionId), { force: true });
|
||||
this.updateCompactionPath(sessionId, undefined);
|
||||
}
|
||||
|
||||
appendStaleSessionHookLog(
|
||||
detectedAt: string,
|
||||
sessionId: string,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/sdk",
|
||||
"description": "Cline SDK - user-facing alias for @cline/core",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.58",
|
||||
"description": "Shared utilities, types, and schemas for Cline packages",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -438,9 +438,11 @@ 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 request projection hook invoked before each model call.
|
||||
*
|
||||
* Returned messages affect only the provider request for the current call.
|
||||
* They do not replace the canonical runtime transcript, are not persisted as
|
||||
* session history, and are not reflected in AgentRunResult.messages.
|
||||
*/
|
||||
prepareTurn?: (
|
||||
context: AgentRuntimePrepareTurnContext,
|
||||
|
||||
@@ -800,8 +800,14 @@ 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 request projection hook invoked before each model call.
|
||||
*
|
||||
* Returned messages affect only the provider request for the current call.
|
||||
* They do not replace the canonical runtime transcript, are not persisted as
|
||||
* session history, and are not reflected in AgentRunResult.messages.
|
||||
*
|
||||
* Hosts that need durable redaction or normalization must apply it before a
|
||||
* message enters the canonical transcript.
|
||||
*/
|
||||
prepareTurn?: (
|
||||
context: AgentPrepareTurnContext,
|
||||
|
||||
@@ -384,6 +384,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