mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
141f294c82 | ||
|
|
30b652ec11 | ||
|
|
c267ef4d6e | ||
|
|
b64923d650 | ||
|
|
801462bb2c | ||
|
|
a5b98c974c | ||
|
|
4bd4b3cf6b | ||
|
|
031d6dc269 | ||
|
|
ce61d741ee | ||
|
|
bfc522989d | ||
|
|
b47de6eff6 | ||
|
|
420b203e96 | ||
|
|
f4041977ad | ||
|
|
2d99576ad8 | ||
|
|
6c46c61801 | ||
|
|
964a14b428 | ||
|
|
8234422493 | ||
|
|
8ab6650965 | ||
|
|
cae2db3345 | ||
|
|
00c7c2373c | ||
|
|
f24e8f8f02 | ||
|
|
9dfe5513a2 | ||
|
|
c1c47a94fe | ||
|
|
2b20623f08 | ||
|
|
7751d75b5c | ||
|
|
4f3f8e9f53 | ||
|
|
e3ab67d34b | ||
|
|
5576e873f8 | ||
|
|
2c4aeae4f3 | ||
|
|
0c027d2731 | ||
|
|
6cc93c124e | ||
|
|
7e5b8be28c |
+3
-1
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.21",
|
||||
"version": "3.0.22",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
+24
-59
@@ -1,11 +1,6 @@
|
||||
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { OAuthCredentials } from "../commands/auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
saveOAuthProviderSettings,
|
||||
toProviderApiKey,
|
||||
} from "../commands/auth";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
@@ -30,37 +25,13 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(
|
||||
providerId: AcpAuthMethodId,
|
||||
existingSettings: ProviderSettings | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
|
||||
await Promise.all([
|
||||
import("@cline/core"),
|
||||
import("open"),
|
||||
import("@cline/core").then((m) => ({
|
||||
loginClineOAuth: m.loginClineOAuth as (input: {
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
apiBaseUrl: string;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>,
|
||||
loginOpenAICodex: m.loginOpenAICodex as (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>,
|
||||
})),
|
||||
]);
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
@@ -82,18 +53,18 @@ async function performOAuthLogin(
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return coreOAuth.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existingSettings?.baseUrl?.trim() ||
|
||||
getClineEnvironmentConfig().apiBaseUrl,
|
||||
callbacks,
|
||||
useWorkOSDeviceAuth: true,
|
||||
});
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// openai-codex
|
||||
return coreOAuth.loginOpenAICodex(callbacks);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
@@ -122,16 +93,10 @@ export async function authenticateAcpProvider(
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const credentials = await performOAuthLogin(methodId, existing);
|
||||
|
||||
saveOAuthProviderSettings(
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
methodId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const apiKey = toProviderApiKey(methodId, credentials);
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
normalizeAuthProviderId,
|
||||
saveOAuthProviderSettings,
|
||||
} from "./auth";
|
||||
|
||||
describe("saveOAuthProviderSettings", () => {
|
||||
it("preserves existing manual apiKey while updating OAuth tokens", () => {
|
||||
@@ -67,6 +71,12 @@ describe("getPersistedProviderApiKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAuthProviderId", () => {
|
||||
it("keeps CLI-only codex shorthand in CLI parsing", () => {
|
||||
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthTuiRuntime", () => {
|
||||
it("loads OpenTUI React after provider catalog initialization", async () => {
|
||||
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
+14
-121
@@ -3,11 +3,13 @@ import {
|
||||
BUILT_IN_PROVIDER,
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
@@ -37,40 +39,6 @@ const c = {
|
||||
green: "\x1b[32m",
|
||||
};
|
||||
|
||||
type CoreOAuthApi = {
|
||||
loginClineOAuth: (input: {
|
||||
apiBaseUrl: string;
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOcaOAuth: (input: {
|
||||
mode?: "internal" | "external";
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOpenAICodex: (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>;
|
||||
};
|
||||
|
||||
type AuthIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -100,27 +68,6 @@ type ParsedAuthCommandArgs = {
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
|
||||
|
||||
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
|
||||
if (!cachedCoreOAuthApi) {
|
||||
cachedCoreOAuthApi = import("@cline/core").then((module) => {
|
||||
const runtimeApi = module as Partial<CoreOAuthApi>;
|
||||
if (
|
||||
typeof runtimeApi.loginClineOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOcaOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOpenAICodex !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
|
||||
);
|
||||
}
|
||||
return runtimeApi as CoreOAuthApi;
|
||||
});
|
||||
}
|
||||
return cachedCoreOAuthApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `auth` subcommand for Commander.
|
||||
*
|
||||
@@ -272,64 +219,18 @@ function createOAuthCallbacks(io: AuthIo): {
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithOAuthProvider(
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
io: AuthIo,
|
||||
): Promise<OAuthCredentials> {
|
||||
const oauthApi = await getCoreOAuthApi();
|
||||
const callbacks = createOAuthCallbacks(io);
|
||||
|
||||
if (providerId === "cline") {
|
||||
return oauthApi.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "oca") {
|
||||
const mode = existing?.oca?.mode;
|
||||
return oauthApi.loginOcaOAuth({
|
||||
mode,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
return oauthApi.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveOAuthProviderSettings(
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: OAuthCredentials,
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
auth.expiresAt = credentials.expires;
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings(merged, {
|
||||
tokenSource: "oauth",
|
||||
return saveProviderOAuthCredentials({
|
||||
manager: providerSettingsManager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function ensureOAuthProviderApiKey(input: {
|
||||
@@ -348,19 +249,14 @@ export async function ensureOAuthProviderApiKey(input: {
|
||||
selectedProviderSettings: input.existingSettings,
|
||||
};
|
||||
}
|
||||
const credentials = await loginWithOAuthProvider(
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
input.io,
|
||||
);
|
||||
const selectedProviderSettings = saveOAuthProviderSettings(
|
||||
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(input.io) },
|
||||
);
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
return {
|
||||
apiKey: toProviderApiKey(input.providerId, credentials),
|
||||
apiKey: handler?.getApiKey(selectedProviderSettings),
|
||||
selectedProviderSettings,
|
||||
};
|
||||
}
|
||||
@@ -515,13 +411,10 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginWithOAuthProvider(providerId, existing, io);
|
||||
saveOAuthProviderSettings(
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
|
||||
@@ -126,7 +126,8 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("falls back to legacy contextWindow for manual compaction", async () => {
|
||||
@@ -157,7 +158,8 @@ describe("compactInteractiveMessages", () => {
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toEqual([messages[0]]);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toEqual([messages[0]]);
|
||||
});
|
||||
|
||||
it("uses a useful target budget for manual compaction", async () => {
|
||||
@@ -174,7 +176,8 @@ describe("compactInteractiveMessages", () => {
|
||||
messages,
|
||||
});
|
||||
|
||||
const compactedTextLength = result.messages.reduce(
|
||||
const compactedMessages = result.compactionState?.messages ?? [];
|
||||
const compactedTextLength = compactedMessages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
(typeof message.content === "string" ? message.content.length : 0),
|
||||
@@ -182,8 +185,9 @@ describe("compactInteractiveMessages", () => {
|
||||
);
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages.length).toBeGreaterThan(1);
|
||||
expect(result.messages.length).toBeLessThan(messages.length);
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(compactedMessages.length).toBeGreaterThan(1);
|
||||
expect(compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(compactedTextLength).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
@@ -214,8 +218,9 @@ describe("compactInteractiveMessages", () => {
|
||||
});
|
||||
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.messages).toHaveLength(messages.length);
|
||||
expect(result.messages[0]?.content).toBe(
|
||||
expect(result.canonicalMessages).toEqual(messages);
|
||||
expect(result.compactionState?.messages).toHaveLength(messages.length);
|
||||
expect(result.compactionState?.messages[0]?.content).toBe(
|
||||
"same count but content should be trimmed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
createContextCompactionPrepareTurn,
|
||||
createSessionCompactionState,
|
||||
type ProviderConfig,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
type ReasoningSettings,
|
||||
type SessionCompactionState,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
@@ -52,7 +54,12 @@ export async function compactInteractiveMessages(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
}): Promise<{ compacted: boolean; messages: Message[] }> {
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{
|
||||
compacted: boolean;
|
||||
canonicalMessages: Message[];
|
||||
compactionState?: SessionCompactionState;
|
||||
}> {
|
||||
const modelInfo = input.config.knownModels?.[input.config.modelId];
|
||||
const maxInputTokens =
|
||||
input.config.compaction?.maxInputTokens ??
|
||||
@@ -81,8 +88,11 @@ export async function compactInteractiveMessages(input: {
|
||||
{ mode: "manual" },
|
||||
);
|
||||
if (!compact) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
// Manual compaction intentionally summarizes the full canonical transcript
|
||||
// instead of reusing a prior sidecar summary, which avoids summary-of-summary
|
||||
// drift across repeated `/compact` calls.
|
||||
const result = await compact({
|
||||
agentId: "cli",
|
||||
conversationId: input.sessionId,
|
||||
@@ -90,7 +100,7 @@ export async function compactInteractiveMessages(input: {
|
||||
iteration: 0,
|
||||
messages: input.messages,
|
||||
apiMessages: input.messages,
|
||||
abortSignal: new AbortController().signal,
|
||||
abortSignal: input.abortSignal ?? new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
model: {
|
||||
@@ -103,8 +113,17 @@ export async function compactInteractiveMessages(input: {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
return { compacted: false, messages: input.messages };
|
||||
if (!result?.messages) {
|
||||
return { compacted: false, canonicalMessages: input.messages };
|
||||
}
|
||||
return { compacted: true, messages: result.messages };
|
||||
return {
|
||||
compacted: true,
|
||||
canonicalMessages: input.messages,
|
||||
compactionState: createSessionCompactionState({
|
||||
sourceMessages: input.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: input.sessionId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,89 +1,124 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
|
||||
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
|
||||
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
|
||||
function makeConfig(): Config {
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
enableAgentTeams: true,
|
||||
verbose: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
function createChatCommandState(): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
enableTools: true,
|
||||
autoApproveTools: true,
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
return {
|
||||
getProviderSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ProviderSettingsManager;
|
||||
}
|
||||
|
||||
function createManifest(sessionId: string): SessionManifest {
|
||||
return {
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: 1,
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
cwd: "/tmp/project",
|
||||
workspace_root: "/tmp/project",
|
||||
enable_tools: true,
|
||||
enable_spawn: true,
|
||||
enable_teams: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function importRuntime() {
|
||||
return await import("./session-runtime");
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
@@ -95,14 +130,14 @@ function makeSwitchToActModeTool(): AgentTool {
|
||||
|
||||
function makeManager() {
|
||||
let startCount = 0;
|
||||
const start = vi.fn(async (_input?: unknown) => {
|
||||
const start = vi.fn(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -114,6 +149,8 @@ function makeManager() {
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
@@ -133,7 +170,7 @@ function makeTurnResult() {
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
@@ -150,43 +187,322 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
async function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
it("manual compact updates the active session sidecar without restarting", async () => {
|
||||
const sessionId = "sess-active";
|
||||
const messages = [
|
||||
{ id: "u1", role: "user" as const, content: "hello" },
|
||||
{ id: "a1", role: "assistant" as const, content: "world" },
|
||||
];
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
compactInteractiveMessagesMock.mockResolvedValue({
|
||||
compacted: true,
|
||||
canonicalMessages: messages,
|
||||
compactionState,
|
||||
});
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.compactCurrentSession();
|
||||
|
||||
expect(result).toEqual({
|
||||
messagesBefore: messages.length,
|
||||
messagesAfter: messages.length,
|
||||
workingContextMessagesAfter: compactionState.messages.length,
|
||||
compacted: true,
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(manager.stop).not.toHaveBeenCalled();
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
}),
|
||||
providerSettingsManager: expect.objectContaining({
|
||||
getProviderSettings: expect.any(Function),
|
||||
}),
|
||||
sessionId,
|
||||
messages,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
compactionState,
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("rejects manual compact while the active session is running", async () => {
|
||||
const sessionId = "sess-running";
|
||||
const messages = [{ role: "user" as const, content: "hello" }];
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
status: "running",
|
||||
}),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"Cannot compact while the current turn is running",
|
||||
);
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries compacted working context across mode-switch restarts", async () => {
|
||||
const firstSessionId = "sess-mode-before";
|
||||
const secondSessionId = "sess-mode-after";
|
||||
const prefixMessage = {
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: "large original",
|
||||
};
|
||||
const tailMessage = {
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: "new canonical tail",
|
||||
};
|
||||
const messages = [prefixMessage, tailMessage];
|
||||
const summaryMessage = {
|
||||
id: "summary",
|
||||
role: "user" as const,
|
||||
content: "summary",
|
||||
};
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: [prefixMessage],
|
||||
compactedMessages: [summaryMessage],
|
||||
conversationId: firstSessionId,
|
||||
systemPrompt: "compacted system",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: firstSessionId,
|
||||
manifest: createManifest(firstSessionId),
|
||||
manifestPath: "/tmp/session-before.json",
|
||||
messagesPath: "/tmp/session-before.messages.json",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: secondSessionId,
|
||||
manifest: createManifest(secondSessionId),
|
||||
manifestPath: "/tmp/session-after.json",
|
||||
messagesPath: "/tmp/session-after.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.applyMode("plan");
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
|
||||
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
|
||||
firstSessionId,
|
||||
);
|
||||
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
|
||||
const restartInput = manager.start.mock.calls[1]?.[0];
|
||||
expect(restartInput).toMatchObject({
|
||||
initialMessages: messages,
|
||||
});
|
||||
expect(restartInput).not.toHaveProperty("initialCompactionState");
|
||||
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
secondSessionId,
|
||||
expect.objectContaining({
|
||||
conversation_id: secondSessionId,
|
||||
source_message_count: messages.length,
|
||||
messages: [summaryMessage, tailMessage],
|
||||
system_prompt: "compacted system",
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
@@ -197,7 +513,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
@@ -206,14 +522,50 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: "resumed-session",
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
@@ -221,16 +573,14 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
config: expect.objectContaining({ sessionId: "resumed-session" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
@@ -247,8 +597,45 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
@@ -270,7 +657,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
@@ -307,7 +694,7 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type CheckpointEntry,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readSessionCheckpointHistory,
|
||||
type SessionCompactionState,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
type ToolApprovalRequest,
|
||||
@@ -79,6 +82,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
let manualCompactionAbortController: AbortController | undefined;
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
@@ -164,6 +168,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
@@ -173,6 +178,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
toolPolicies: input.config.toolPolicies,
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(initialCompactionState ? { initialCompactionState } : {}),
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
onTeamRestored: () => {},
|
||||
@@ -281,6 +287,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await missingSessionRecoveryPromise;
|
||||
};
|
||||
|
||||
const readCurrentCompactionState = async (): Promise<
|
||||
SessionCompactionState | undefined
|
||||
> => {
|
||||
if (!sessionManager || !activeSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return await sessionManager
|
||||
.readSessionCompactionState(activeSessionId)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
@@ -318,6 +335,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
@@ -325,12 +343,37 @@ export function createInteractiveSessionRuntime(input: {
|
||||
startupError = undefined;
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
await startFreshSession(messages, sessionMetadata, initialCompactionState);
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const messages = await readCurrentMessages();
|
||||
const [messages, compactionState] = await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
]);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await restartWithMessages(messages);
|
||||
if (!projectedMessages || !sessionManager || !activeSessionId) {
|
||||
return;
|
||||
}
|
||||
const reanchoredCompactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
conversationId: activeSessionId,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
});
|
||||
const updated = await sessionManager.updateSessionCompactionState(
|
||||
activeSessionId,
|
||||
reanchoredCompactionState,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
input.config.logger?.log?.(
|
||||
"Skipped re-anchoring session compaction state after restart",
|
||||
{ sessionId: activeSessionId },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const restartEmpty = async (): Promise<void> => {
|
||||
@@ -444,6 +487,12 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Cannot fork an empty session.");
|
||||
}
|
||||
const compactionState = await manager
|
||||
.readSessionCompactionState(forkedFromSessionId)
|
||||
.catch(() => undefined);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
await manager.stop(forkedFromSessionId);
|
||||
const forkMetadata = buildForkSessionMetadata({
|
||||
forkedFromSessionId,
|
||||
@@ -452,6 +501,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages,
|
||||
});
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
if (projectedMessages && activeSessionId) {
|
||||
const reanchoredCompactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
conversationId: activeSessionId,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
});
|
||||
const updated = await manager.updateSessionCompactionState(
|
||||
activeSessionId,
|
||||
reanchoredCompactionState,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
input.config.logger?.log?.(
|
||||
"Skipped re-anchoring session compaction state after fork",
|
||||
{ sessionId: activeSessionId },
|
||||
);
|
||||
}
|
||||
}
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
};
|
||||
|
||||
@@ -473,22 +540,41 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const compactCurrentSession = async (): Promise<{
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}> => {
|
||||
if (!sessionManager) {
|
||||
const manager = sessionManager;
|
||||
const sourceSessionId = activeSessionId;
|
||||
if (!manager || !sourceSessionId) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const messages = await readCurrentMessages();
|
||||
const messages = (await manager.readMessages(sourceSessionId)) ?? [];
|
||||
const messagesBefore = messages.length;
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: activeSessionId,
|
||||
messages,
|
||||
});
|
||||
const sessionRecord = await manager.get(sourceSessionId);
|
||||
if (sessionRecord?.status === "running") {
|
||||
throw new Error(
|
||||
"Cannot compact while the current turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof compactInteractiveMessages>>;
|
||||
const abortController = new AbortController();
|
||||
manualCompactionAbortController = abortController;
|
||||
try {
|
||||
result = await compactInteractiveMessages({
|
||||
config: input.config,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
sessionId: sourceSessionId,
|
||||
messages,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
} finally {
|
||||
if (manualCompactionAbortController === abortController) {
|
||||
manualCompactionAbortController = undefined;
|
||||
}
|
||||
}
|
||||
if (!result.compacted) {
|
||||
return {
|
||||
messagesBefore,
|
||||
@@ -496,10 +582,24 @@ export function createInteractiveSessionRuntime(input: {
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
await restartWithMessages(result.messages);
|
||||
if (!result.compactionState) {
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: messagesBefore,
|
||||
compacted: false,
|
||||
};
|
||||
}
|
||||
const updated = await manager.updateSessionCompactionState(
|
||||
sourceSessionId,
|
||||
result.compactionState,
|
||||
);
|
||||
if (!updated.updated) {
|
||||
throw new Error("Compaction could not be saved. Try again.");
|
||||
}
|
||||
return {
|
||||
messagesBefore,
|
||||
messagesAfter: result.messages.length,
|
||||
messagesAfter: result.canonicalMessages.length,
|
||||
workingContextMessagesAfter: result.compactionState?.messages.length,
|
||||
compacted: true,
|
||||
};
|
||||
};
|
||||
@@ -586,6 +686,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
}
|
||||
abortRequested = true;
|
||||
markAbortInProgress();
|
||||
manualCompactionAbortController?.abort(
|
||||
new Error("Interactive runtime abort requested"),
|
||||
);
|
||||
sessionManager
|
||||
.abort(activeSessionId, new Error("Interactive runtime abort requested"))
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
@@ -9,13 +9,14 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
getValidClineCredentials: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
@@ -32,7 +33,6 @@ vi.mock("@cline/core", () => {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
getValidClineCredentials: coreMocks.getValidClineCredentials,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -59,15 +59,51 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.getValidClineCredentials.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -77,26 +113,12 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(coreMocks.getValidClineCredentials).toHaveBeenCalledWith(
|
||||
{
|
||||
access: "old-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 1,
|
||||
accountId: "acct-old",
|
||||
},
|
||||
{ apiBaseUrl: "https://api.cline.bot" },
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
@@ -115,6 +137,14 @@ describe("createClineAccountService", () => {
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -123,7 +153,6 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue(null);
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
|
||||
@@ -4,16 +4,18 @@ import {
|
||||
type ClineAccountOrganizationBalance,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { toProviderApiKey } from "../utils/provider-auth";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
@@ -69,26 +71,13 @@ function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const persistedAccessToken =
|
||||
input.clineProviderSettings?.auth?.accessToken?.trim() || "";
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
const settingsApiKey =
|
||||
input.clineProviderSettings?.apiKey?.trim() ||
|
||||
input.clineProviderSettings?.auth?.apiKey?.trim() ||
|
||||
"";
|
||||
|
||||
let authToken = persistedAccessToken || configApiKey || settingsApiKey;
|
||||
if (authToken.toLowerCase().startsWith("workos:workos:")) {
|
||||
authToken = authToken.slice("workos:".length);
|
||||
}
|
||||
return authToken || undefined;
|
||||
}
|
||||
|
||||
function stripWorkosTokenPrefix(accessToken: string): string {
|
||||
return accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? accessToken.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: accessToken;
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
@@ -98,43 +87,26 @@ async function resolveValidClineAccountAuthToken(input: {
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const auth = settings?.auth;
|
||||
const accessToken = auth?.accessToken?.trim();
|
||||
const refreshToken = auth?.refreshToken?.trim();
|
||||
if (settings && auth && accessToken && refreshToken) {
|
||||
const credentials = await getValidClineCredentials(
|
||||
{
|
||||
access: stripWorkosTokenPrefix(accessToken),
|
||||
refresh: refreshToken,
|
||||
expires: auth.expiresAt ?? Date.now() - 1,
|
||||
accountId: auth.accountId,
|
||||
},
|
||||
{ apiBaseUrl: input.apiBaseUrl },
|
||||
);
|
||||
if (!credentials) {
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = toProviderApiKey("cline", credentials);
|
||||
if (
|
||||
nextAccessToken !== accessToken ||
|
||||
credentials.refresh !== refreshToken ||
|
||||
credentials.accountId !== auth.accountId ||
|
||||
credentials.expires !== auth.expiresAt
|
||||
) {
|
||||
input.manager.saveProviderSettings(
|
||||
{
|
||||
...settings,
|
||||
auth: {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: nextAccessToken,
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
@@ -671,7 +671,7 @@ export function OAuthLoginContent(
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
@@ -705,30 +705,24 @@ export function OAuthLoginContent(
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
|
||||
loginLocalProvider(
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
existing,
|
||||
(url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
loginLocalProvider(providerId, existing, (url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
});
|
||||
} catch {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
}
|
||||
})
|
||||
.then((credentials) => {
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("formatCompactionStatus", () => {
|
||||
messagesAfter: 300,
|
||||
compacted: true,
|
||||
}),
|
||||
).toBe("Compacted context; message count stayed at 300.");
|
||||
).toBe("Compacted context; message count stayed at 300 messages.");
|
||||
});
|
||||
|
||||
it("reports empty sessions separately", () => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ResumedSessionResult {
|
||||
export interface InteractiveCompactionResult {
|
||||
messagesBefore: number;
|
||||
messagesAfter: number;
|
||||
workingContextMessagesAfter?: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { InteractiveCompactionResult } from "../types";
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
@@ -9,8 +13,11 @@ export function formatCompactionStatus(
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; canonical history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
type ITelemetryService,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
@@ -9,16 +10,12 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
|
||||
export type OnboardingOAuthProviderId = "cline" | "oca" | "openai-codex";
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
export function isOnboardingOAuthProviderId(
|
||||
providerId: string,
|
||||
): providerId is OnboardingOAuthProviderId {
|
||||
return (
|
||||
providerId === "cline" ||
|
||||
providerId === "oca" ||
|
||||
providerId === "openai-codex"
|
||||
);
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import type { AgentEvent, TeamEvent } from "@cline/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleEvent, handleTeamEvent } from "./events";
|
||||
import {
|
||||
handleEvent,
|
||||
handleTeamEvent,
|
||||
resolveStatusNoticeLabel,
|
||||
} from "./events";
|
||||
import { setCurrentOutputMode } from "./output";
|
||||
import type { Config } from "./types";
|
||||
|
||||
describe("resolveStatusNoticeLabel", () => {
|
||||
it("maps compaction status reasons to stable labels", () => {
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "auto-compacting",
|
||||
reason: "auto_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("auto-compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "manual",
|
||||
reason: "manual_compaction",
|
||||
} as AgentEvent),
|
||||
).toBe("compacting");
|
||||
expect(
|
||||
resolveStatusNoticeLabel({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction-budget-adjusted",
|
||||
reason: "compaction_budget_emergency",
|
||||
} as AgentEvent),
|
||||
).toBe("context budget adjusted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent text formatting", () => {
|
||||
let output = "";
|
||||
|
||||
|
||||
@@ -27,8 +27,13 @@ export function resolveStatusNoticeLabel(
|
||||
if (event.type !== "notice" || event.displayRole !== "status") {
|
||||
return undefined;
|
||||
}
|
||||
if (event.reason === "auto_compaction") {
|
||||
return "auto-compacting";
|
||||
switch (event.reason) {
|
||||
case "auto_compaction":
|
||||
return "auto-compacting";
|
||||
case "manual_compaction":
|
||||
return "compacting";
|
||||
case "compaction_budget_emergency":
|
||||
return "context budget adjusted";
|
||||
}
|
||||
return event.message.trim() || undefined;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Llms, type ProviderSettings } from "@cline/core";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
|
||||
export type OAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export type OAuthCredentials = ProviderOAuthCredentials;
|
||||
|
||||
export function normalizeProviderId(providerId: string): string {
|
||||
return Llms.normalizeProviderId(providerId.trim());
|
||||
@@ -22,42 +21,20 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exports `isOAuthProviderId` from `@cline/shared` so the CLI has a
|
||||
* single source of truth for the OAuth provider list. Existing call sites
|
||||
* keep their `isOAuthProvider` import name.
|
||||
*/
|
||||
export const isOAuthProvider = isOAuthProviderId;
|
||||
export { isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<OAuthCredentials, "access">,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
return formatProviderOAuthApiKey(providerId, credentials);
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return toProviderApiKey(providerId, { access: accessToken });
|
||||
}
|
||||
const shorthandKey = settings?.apiKey?.trim();
|
||||
if (shorthandKey) {
|
||||
return shorthandKey;
|
||||
}
|
||||
const authKey = settings?.auth?.apiKey?.trim();
|
||||
if (authKey) {
|
||||
return authKey;
|
||||
}
|
||||
return undefined;
|
||||
return getCorePersistedProviderApiKey(providerId, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +53,7 @@ export function isProviderConfigured(
|
||||
settings: ProviderSettings | undefined,
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProviderId(providerId)) {
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -117,17 +116,10 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
providerId,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -134,17 +133,10 @@ export async function runProviderOAuthLogin(
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeOAuthProvider(providerId);
|
||||
const existing = providerSettingsManager.getProviderSettings(normalized);
|
||||
const credentials = await loginLocalProvider(
|
||||
normalized,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setDisabledPlugin,
|
||||
@@ -1012,10 +1011,9 @@ export async function handleCommand(
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
(url) => {
|
||||
const platform = process.platform;
|
||||
const spawned =
|
||||
@@ -1033,12 +1031,6 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
|
||||
@@ -81,11 +81,12 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const isDeepSeekReasonerModel = model.id.includes("deepseek-reasoner")
|
||||
const isDeepSeekThinkingModel =
|
||||
model.id.includes("deepseek-reasoner") || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
isDeepSeekReasonerModel || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekThinkingModel
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
@@ -104,6 +105,13 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -115,13 +123,6 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
|
||||
+105
-1
@@ -15,5 +15,109 @@
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
"apps/vscode/**"
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "info",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "info",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "info",
|
||||
"useYield": "info",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "info",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "info",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "info",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "info",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "info",
|
||||
"useIterableCallbackReturn": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "info",
|
||||
"useOptionalChain": "info",
|
||||
"noBannedTypes": "warn",
|
||||
"useLiteralKeys": "info",
|
||||
"noUselessCatch": "info",
|
||||
"noUselessSwitchCase": "info",
|
||||
"noStaticOnlyClass": "info"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 130,
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none",
|
||||
"expand": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+6
-1
@@ -323,15 +323,20 @@ Context compaction is owned by `core`.
|
||||
|
||||
- `@cline/agents` owns the generic turn-preparation seam:
|
||||
- run normal lifecycle hooks
|
||||
- allow hosts to rewrite message history or system prompt before the provider call
|
||||
- allow hosts to project message history or system prompt before the provider call
|
||||
- keep its canonical runtime transcript append-only when a projection is returned
|
||||
- `@cline/core` owns compaction policy:
|
||||
- inject a prepare-turn pipeline for root sessions
|
||||
- choose between built-in strategies through a registry map
|
||||
- persist the latest compacted working context as a session compaction artifact
|
||||
- keep compaction logic out of the low-level agent message builder
|
||||
|
||||
Design implications:
|
||||
|
||||
- compaction is a context-pipeline concern owned by `core`
|
||||
- canonical session history lives in the session messages artifact at full fidelity; compaction state lives separately in `${sessionId}.compaction.json`
|
||||
- resume loads the canonical transcript for history/debugging and, when present, reuses the latest compaction state only after validating a hash of the canonical prefix covered by that state; valid state is projected by appending canonical messages written after the compaction boundary
|
||||
- sessions that were already persisted with compacted messages before this model are best-effort only because the omitted original transcript is not recoverable from the compacted artifact
|
||||
- `agents` stays focused on the stateless loop and provider/tool orchestration
|
||||
- delegated/subagent flows should inherit compaction behavior through core session config, not through a separate agent-level compaction hook surface
|
||||
|
||||
|
||||
@@ -902,7 +902,7 @@ describe("AgentRuntime", () => {
|
||||
expect(model.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs prepareTurn before beforeModel and persists rewritten messages", async () => {
|
||||
it("runs prepareTurn before beforeModel without overwriting canonical messages", async () => {
|
||||
const compactedMessage: AgentMessage = {
|
||||
id: "msg_compacted",
|
||||
role: "user",
|
||||
@@ -955,7 +955,10 @@ describe("AgentRuntime", () => {
|
||||
expect(prepareTurn).toHaveBeenCalledTimes(1);
|
||||
expect(beforeModel).toHaveBeenCalledTimes(1);
|
||||
expect(notices).toEqual(["auto-compacting"]);
|
||||
expect(result.messages[0]).toEqual(compactedMessage);
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "large context" }],
|
||||
});
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(model.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -753,8 +753,15 @@ export class AgentRuntime {
|
||||
};
|
||||
|
||||
if (this.state.iteration > 1) {
|
||||
if (await this.consumePendingUserMessage()) {
|
||||
request = { ...request, messages: cloneMessages(this.state.messages) };
|
||||
const pendingUserMessage = await this.consumePendingUserMessage();
|
||||
if (pendingUserMessage) {
|
||||
request = {
|
||||
...request,
|
||||
messages: [
|
||||
...request.messages,
|
||||
...cloneMessages([pendingUserMessage]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,7 +1014,6 @@ export class AgentRuntime {
|
||||
let next = request;
|
||||
if (result.messages) {
|
||||
const preparedMessages = cloneMessages(result.messages);
|
||||
this.state.messages = preparedMessages;
|
||||
next = { ...next, messages: cloneMessages(preparedMessages) };
|
||||
}
|
||||
if (result.systemPrompt !== undefined) {
|
||||
@@ -1016,14 +1022,14 @@ export class AgentRuntime {
|
||||
return next;
|
||||
}
|
||||
|
||||
private async consumePendingUserMessage(): Promise<boolean> {
|
||||
private async consumePendingUserMessage(): Promise<AgentMessage | undefined> {
|
||||
const consumePendingUserMessage = this.config.consumePendingUserMessage;
|
||||
if (!consumePendingUserMessage) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
const pending = (await consumePendingUserMessage())?.trim();
|
||||
if (!pending) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
const message = createMessage("user", [{ type: "text", text: pending }]);
|
||||
this.state.messages.push(message);
|
||||
@@ -1032,7 +1038,7 @@ export class AgentRuntime {
|
||||
snapshot: this.snapshot(),
|
||||
message,
|
||||
});
|
||||
return true;
|
||||
return message;
|
||||
}
|
||||
|
||||
private async updateUsage(usage: Partial<AgentUsage>): Promise<void> {
|
||||
|
||||
@@ -464,6 +464,18 @@ export class ClineCore {
|
||||
*/
|
||||
update: RuntimeHost["updateSession"] = (...args) =>
|
||||
this.host.updateSession(...args);
|
||||
/**
|
||||
* Stores the compacted working-context state for an existing session.
|
||||
*/
|
||||
updateSessionCompactionState: RuntimeHost["updateSessionCompactionState"] = (
|
||||
...args
|
||||
) => this.host.updateSessionCompactionState(...args);
|
||||
/**
|
||||
* Reads the compacted working-context sidecar for a session, if one exists.
|
||||
*/
|
||||
readSessionCompactionState: RuntimeHost["readSessionCompactionState"] = (
|
||||
...args
|
||||
) => this.host.readSessionCompactionState(...args);
|
||||
/**
|
||||
* Reads message history for a session.
|
||||
*
|
||||
|
||||
@@ -22,7 +22,6 @@ const socketBindingSupported = await (async () => {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const _socketIt = socketBindingSupported ? it : it.skip;
|
||||
|
||||
function createCredentials(
|
||||
overrides: Partial<ClineOAuthCredentials> = {},
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
identifyAccount,
|
||||
} from "../services/telemetry/core-events";
|
||||
import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
|
||||
import {
|
||||
isCredentialLikelyExpired,
|
||||
parseAuthorizationInput,
|
||||
@@ -701,22 +697,3 @@ export async function getValidClineCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createClineOAuthProvider(
|
||||
options: ClineOAuthProviderOptions,
|
||||
): OAuthProviderInterface {
|
||||
return {
|
||||
id: "cline",
|
||||
name: "Cline Account",
|
||||
usesCallbackServer: !(options.useWorkOSDeviceAuth ?? true),
|
||||
async login(callbacks) {
|
||||
return loginClineOAuth({ ...options, callbacks });
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return refreshClineToken(credentials as ClineOAuthCredentials, options);
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return `workos:${credentials.access}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getValidOpenAICodexCredentials,
|
||||
normalizeOpenAICodexCredentials,
|
||||
refreshOpenAICodexToken,
|
||||
} from "./codex";
|
||||
import type { OAuthCredentials } from "./types";
|
||||
@@ -138,19 +137,6 @@ describe("auth/codex token lifecycle", () => {
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("normalizes credentials by deriving accountId from access token", () => {
|
||||
const accessToken = createJwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-derived" },
|
||||
});
|
||||
const normalized = normalizeOpenAICodexCredentials({
|
||||
access: accessToken,
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
});
|
||||
expect(normalized.accountId).toBe("acct-derived");
|
||||
expect(normalized.metadata).toMatchObject({ provider: "openai-codex" });
|
||||
});
|
||||
|
||||
it("refreshOpenAICodexToken throws when response is structurally invalid", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
identifyAccount,
|
||||
} from "../services/telemetry/core-events";
|
||||
import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
import type { OAuthCredentials, OAuthPrompt } from "./types";
|
||||
import {
|
||||
decodeJwtPayload,
|
||||
getProofKey,
|
||||
@@ -442,50 +437,3 @@ export async function getValidOpenAICodexCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAICodexTokenExpired(
|
||||
credentials: OAuthCredentials,
|
||||
refreshBufferMs: number = OPENAI_CODEX_OAUTH_CONFIG.refreshBufferMs,
|
||||
): boolean {
|
||||
return isCredentialLikelyExpired(credentials, refreshBufferMs);
|
||||
}
|
||||
|
||||
export function normalizeOpenAICodexCredentials(
|
||||
credentials: OAuthCredentials,
|
||||
): OAuthCredentials {
|
||||
const accountId = credentials.accountId ?? getAccountId(credentials.access);
|
||||
if (!accountId) {
|
||||
throw new Error("Failed to extract accountId from token");
|
||||
}
|
||||
return {
|
||||
...credentials,
|
||||
accountId,
|
||||
metadata: {
|
||||
...(credentials.metadata ?? {}),
|
||||
provider: "openai-codex",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
|
||||
id: "openai-codex",
|
||||
name: "ChatGPT Plus/Pro (ChatGPT Subscription)",
|
||||
usesCallbackServer: true,
|
||||
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
return loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
});
|
||||
},
|
||||
|
||||
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||
return refreshOpenAICodexToken(credentials.refresh, credentials);
|
||||
},
|
||||
|
||||
getApiKey(credentials: OAuthCredentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthProviderInterface,
|
||||
OcaClientMetadata,
|
||||
OcaMode,
|
||||
OcaOAuthConfig,
|
||||
OcaOAuthProviderOptions,
|
||||
@@ -525,64 +523,3 @@ export async function getValidOcaCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOcaOAuthProvider(
|
||||
options: OcaOAuthProviderOptions = {},
|
||||
): OAuthProviderInterface {
|
||||
return {
|
||||
id: "oca",
|
||||
name: "Oracle Code Assist",
|
||||
usesCallbackServer: true,
|
||||
async login(callbacks) {
|
||||
return loginOcaOAuth({ ...options, callbacks });
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return refreshOcaToken(credentials, options);
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateOcaOpcRequestId(
|
||||
taskId: string,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const hash8 = async (value: string): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest).slice(0, 4), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
};
|
||||
|
||||
const [tokenHex, taskHex] = await Promise.all([hash8(token), hash8(taskId)]);
|
||||
const timestampHex = Math.floor(Date.now() / 1000)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
const randomPart = new Uint32Array(1);
|
||||
crypto.getRandomValues(randomPart);
|
||||
const randomHex = (randomPart[0] ?? 0).toString(16).padStart(8, "0");
|
||||
return tokenHex + taskHex + timestampHex + randomHex;
|
||||
}
|
||||
|
||||
export async function createOcaRequestHeaders(input: {
|
||||
accessToken: string;
|
||||
taskId: string;
|
||||
metadata?: OcaClientMetadata;
|
||||
}): Promise<Record<string, string>> {
|
||||
const opcRequestId = await generateOcaOpcRequestId(
|
||||
input.taskId,
|
||||
input.accessToken,
|
||||
);
|
||||
return {
|
||||
Authorization: `Bearer ${input.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
client: input.metadata?.client ?? "Cline",
|
||||
"client-version": input.metadata?.clientVersion ?? "unknown",
|
||||
"client-ide": input.metadata?.clientIde ?? "unknown",
|
||||
"client-ide-version": input.metadata?.clientIdeVersion ?? "unknown",
|
||||
[OCI_HEADER_OPC_REQUEST_ID]: opcRequestId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderAuthHandler,
|
||||
getProviderAuthStorageId,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
} from "./provider-auth-registry";
|
||||
|
||||
const { loginClineOAuth } = vi.hoisted(() => ({
|
||||
loginClineOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cline", () => ({
|
||||
getValidClineCredentials: vi.fn(),
|
||||
loginClineOAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./oca", () => ({
|
||||
getValidOcaCredentials: vi.fn(),
|
||||
loginOcaOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./codex", () => ({
|
||||
getValidOpenAICodexCredentials: vi.fn(),
|
||||
loginOpenAICodex: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("provider auth registry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns handlers for managed OAuth providers only", () => {
|
||||
expect(getProviderAuthHandler("cline")?.providerId).toBe("cline");
|
||||
expect(getProviderAuthHandler("oca")?.providerId).toBe("oca");
|
||||
expect(getProviderAuthHandler("openai-codex")?.providerId).toBe(
|
||||
"openai-codex",
|
||||
);
|
||||
expect(getProviderAuthHandler("openai-codex-cli")).toBeUndefined();
|
||||
expect(isOAuthProvider("openai-codex-cli")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns storage provider IDs from handlers", () => {
|
||||
expect(getProviderAuthStorageId("cline")).toBe("cline");
|
||||
expect(getProviderAuthStorageId("oca")).toBe("oca");
|
||||
expect(getProviderAuthStorageId("openai-codex")).toBe("openai-codex");
|
||||
expect(getProviderAuthStorageId("openai-codex-cli")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats Cline WorkOS tokens without double-prefixing", () => {
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "workos:abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(
|
||||
getPersistedProviderApiKey("cline", {
|
||||
provider: "cline",
|
||||
auth: { accessToken: "abc" },
|
||||
}),
|
||||
).toBe("workos:abc");
|
||||
});
|
||||
|
||||
it("login/save stores credentials under handler storageProviderId", async () => {
|
||||
loginClineOAuth.mockResolvedValueOnce({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
const getProviderSettings = vi.fn().mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
});
|
||||
const saveProviderSettings = vi.fn();
|
||||
const manager = {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
} as never;
|
||||
|
||||
const saved = await loginAndSaveProviderOAuthCredentials(manager, "cline", {
|
||||
callbacks: {
|
||||
onAuth: vi.fn(),
|
||||
onPrompt: vi.fn(async () => ""),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getProviderSettings).toHaveBeenCalledWith("cline");
|
||||
expect(saved).toMatchObject({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
auth: {
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
},
|
||||
});
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "cline" }),
|
||||
{ tokenSource: "oauth" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
} from "@cline/shared";
|
||||
import type { ProviderSettingsManager } from "../services/storage/provider-settings-manager";
|
||||
import type { ProviderSettings } from "../types/provider-settings";
|
||||
import {
|
||||
type ClineOAuthCredentials,
|
||||
getValidClineCredentials,
|
||||
loginClineOAuth,
|
||||
} from "./cline";
|
||||
import { getValidOpenAICodexCredentials, loginOpenAICodex } from "./codex";
|
||||
import { getValidOcaCredentials, loginOcaOAuth } from "./oca";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
|
||||
import { decodeJwtPayload } from "./utils";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
|
||||
export type ProviderOAuthCredentials = OAuthCredentials;
|
||||
|
||||
export interface ProviderAuthLoginInput {
|
||||
settings?: ProviderSettings;
|
||||
callbacks: OAuthLoginCallbacks;
|
||||
telemetry?: ITelemetryService;
|
||||
}
|
||||
|
||||
export interface ProviderAuthRefreshInput {
|
||||
settings: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
forceRefresh?: boolean;
|
||||
telemetry?: ITelemetryService;
|
||||
}
|
||||
|
||||
export interface ProviderAuthSaveCredentialsInput {
|
||||
manager: ProviderSettingsManager;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAuthHandler {
|
||||
providerId: string;
|
||||
storageProviderId: string;
|
||||
getApiKey(settings: ProviderSettings | undefined): string | undefined;
|
||||
login(input: ProviderAuthLoginInput): Promise<ProviderOAuthCredentials>;
|
||||
refresh(
|
||||
input: ProviderAuthRefreshInput,
|
||||
): Promise<ProviderOAuthCredentials | null>;
|
||||
saveCredentials(input: ProviderAuthSaveCredentialsInput): ProviderSettings;
|
||||
isConfigured(settings: ProviderSettings | undefined): boolean;
|
||||
normalizeStoredAccessToken?(accessToken: string): string;
|
||||
}
|
||||
|
||||
function formatClineApiKey(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? token
|
||||
: `${WORKOS_TOKEN_PREFIX}${token}`;
|
||||
}
|
||||
|
||||
function stripClineApiKeyPrefix(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? token.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: token;
|
||||
}
|
||||
|
||||
function readExpiryFromToken(accessToken: string): number | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const exp = payload?.exp;
|
||||
if (typeof exp === "number" && exp > 0) {
|
||||
return exp * 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveCredentialExpiry(
|
||||
settings: ProviderSettings,
|
||||
normalizedAccessToken: string,
|
||||
): number {
|
||||
const explicitExpiry = settings.auth?.expiresAt;
|
||||
if (
|
||||
typeof explicitExpiry === "number" &&
|
||||
Number.isFinite(explicitExpiry) &&
|
||||
explicitExpiry > 0
|
||||
) {
|
||||
return explicitExpiry;
|
||||
}
|
||||
|
||||
const jwtExpiry = readExpiryFromToken(normalizedAccessToken);
|
||||
if (jwtExpiry) {
|
||||
return jwtExpiry;
|
||||
}
|
||||
|
||||
// Unknown expiry should trigger refresh on next resolution.
|
||||
return Date.now() - 1;
|
||||
}
|
||||
|
||||
function createCredentialsFromSettings(
|
||||
settings: ProviderSettings,
|
||||
options?: { normalizeAccessToken?: (accessToken: string) => string },
|
||||
): ProviderOAuthCredentials | null {
|
||||
const rawAccess = settings.auth?.accessToken?.trim();
|
||||
const refreshToken = settings.auth?.refreshToken?.trim();
|
||||
if (!rawAccess || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const access = options?.normalizeAccessToken?.(rawAccess) ?? rawAccess;
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh: refreshToken,
|
||||
expires: deriveCredentialExpiry(settings, access),
|
||||
accountId: settings.auth?.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function saveOAuthCredentials(input: {
|
||||
manager: ProviderSettingsManager;
|
||||
storageProviderId: string;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
formatAccessToken?: (accessToken: string) => string;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}): ProviderSettings {
|
||||
const accessToken =
|
||||
input.formatAccessToken?.(input.credentials.access) ??
|
||||
input.credentials.access;
|
||||
const auth = {
|
||||
...(input.settings?.auth ?? {}),
|
||||
accessToken,
|
||||
refreshToken: input.credentials.refresh,
|
||||
accountId: input.credentials.accountId,
|
||||
expiresAt: input.credentials.expires,
|
||||
};
|
||||
|
||||
const merged: ProviderSettings = {
|
||||
...(input.settings ?? {
|
||||
provider: input.storageProviderId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: input.storageProviderId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
if (input.save !== false) {
|
||||
input.manager.saveProviderSettings(merged, {
|
||||
...(input.setLastUsed === undefined
|
||||
? {}
|
||||
: { setLastUsed: input.setLastUsed }),
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function createOAuthHandler(input: {
|
||||
providerId: string;
|
||||
storageProviderId?: string;
|
||||
formatAccessToken?: (accessToken: string) => string;
|
||||
normalizeStoredAccessToken?: (accessToken: string) => string;
|
||||
login: (input: ProviderAuthLoginInput) => Promise<ProviderOAuthCredentials>;
|
||||
refresh: (
|
||||
input: ProviderAuthRefreshInput,
|
||||
) => Promise<ProviderOAuthCredentials | null>;
|
||||
}): ProviderAuthHandler {
|
||||
const storageProviderId = input.storageProviderId ?? input.providerId;
|
||||
return {
|
||||
providerId: input.providerId,
|
||||
storageProviderId,
|
||||
getApiKey(settings) {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return input.formatAccessToken?.(accessToken) ?? accessToken;
|
||||
}
|
||||
|
||||
return (
|
||||
settings?.apiKey?.trim() || settings?.auth?.apiKey?.trim() || undefined
|
||||
);
|
||||
},
|
||||
login: input.login,
|
||||
refresh: input.refresh,
|
||||
saveCredentials(saveInput) {
|
||||
return saveOAuthCredentials({
|
||||
...saveInput,
|
||||
storageProviderId,
|
||||
formatAccessToken: input.formatAccessToken,
|
||||
});
|
||||
},
|
||||
isConfigured(settings) {
|
||||
return !!settings?.auth?.accessToken;
|
||||
},
|
||||
normalizeStoredAccessToken: input.normalizeStoredAccessToken,
|
||||
};
|
||||
}
|
||||
|
||||
const providerAuthHandlers = [
|
||||
createOAuthHandler({
|
||||
providerId: "cline",
|
||||
formatAccessToken: formatClineApiKey,
|
||||
normalizeStoredAccessToken: stripClineApiKeyPrefix,
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
telemetry,
|
||||
}),
|
||||
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
|
||||
getValidClineCredentials(
|
||||
credentials as ClineOAuthCredentials,
|
||||
{
|
||||
apiBaseUrl:
|
||||
settings.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
telemetry,
|
||||
},
|
||||
{ forceRefresh },
|
||||
),
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "oca",
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
loginOcaOAuth({ mode: settings?.oca?.mode, callbacks, telemetry }),
|
||||
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
|
||||
getValidOcaCredentials(
|
||||
credentials,
|
||||
{ forceRefresh, telemetry },
|
||||
{ mode: settings.oca?.mode, telemetry },
|
||||
),
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "openai-codex",
|
||||
login: ({ callbacks, telemetry }) =>
|
||||
loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
telemetry,
|
||||
}),
|
||||
refresh: ({ credentials, forceRefresh, telemetry }) =>
|
||||
getValidOpenAICodexCredentials(credentials, { forceRefresh, telemetry }),
|
||||
}),
|
||||
] as const satisfies readonly ProviderAuthHandler[];
|
||||
|
||||
const providerAuthHandlerById = new Map<string, ProviderAuthHandler>(
|
||||
providerAuthHandlers.map((handler) => [handler.providerId, handler]),
|
||||
);
|
||||
|
||||
export function getProviderAuthHandler(
|
||||
providerId: string,
|
||||
): ProviderAuthHandler | undefined {
|
||||
return providerAuthHandlerById.get(providerId.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isOAuthProvider(providerId: string): boolean {
|
||||
return getProviderAuthHandler(providerId) !== undefined;
|
||||
}
|
||||
|
||||
export function getProviderAuthStorageId(
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
return getProviderAuthHandler(providerId)?.storageProviderId;
|
||||
}
|
||||
|
||||
export function resolveProviderApiKeyFromSettings(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
const storageProviderId = handler?.storageProviderId ?? providerId;
|
||||
const settings = manager.getProviderSettings(storageProviderId);
|
||||
return (
|
||||
handler?.getApiKey(settings) ??
|
||||
getPersistedProviderApiKey(providerId, settings)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loginAndSaveProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
input: {
|
||||
callbacks: OAuthLoginCallbacks;
|
||||
telemetry?: ITelemetryService;
|
||||
},
|
||||
): Promise<ProviderSettings> {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
throw new Error(`Provider "${providerId}" does not support OAuth login`);
|
||||
}
|
||||
const existing = manager.getProviderSettings(handler.storageProviderId);
|
||||
const credentials = await handler.login({
|
||||
settings: existing,
|
||||
callbacks: input.callbacks,
|
||||
telemetry: input.telemetry,
|
||||
});
|
||||
return handler.saveCredentials({ manager, settings: existing, credentials });
|
||||
}
|
||||
|
||||
export function getProviderOAuthCredentialsFromSettings(
|
||||
providerId: string,
|
||||
settings: ProviderSettings,
|
||||
): ProviderOAuthCredentials | null {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) return null;
|
||||
return createCredentialsFromSettings(settings, {
|
||||
normalizeAccessToken: handler.normalizeStoredAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function saveProviderOAuthCredentials(input: {
|
||||
manager: ProviderSettingsManager;
|
||||
providerId: string;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}): ProviderSettings {
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Provider "${input.providerId}" does not support OAuth credentials`,
|
||||
);
|
||||
}
|
||||
return handler.saveCredentials({
|
||||
manager: input.manager,
|
||||
settings: input.settings,
|
||||
credentials: input.credentials,
|
||||
setLastUsed: input.setLastUsed,
|
||||
save: input.save,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (handler) {
|
||||
return handler.getApiKey(settings);
|
||||
}
|
||||
|
||||
return (
|
||||
settings?.auth?.accessToken?.trim() ||
|
||||
settings?.apiKey?.trim() ||
|
||||
settings?.auth?.apiKey?.trim() ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function formatProviderOAuthApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<ProviderOAuthCredentials, "access">,
|
||||
): string {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) return credentials.access;
|
||||
|
||||
return (
|
||||
handler.getApiKey({
|
||||
provider: handler.storageProviderId,
|
||||
auth: { accessToken: credentials.access },
|
||||
}) ?? credentials.access
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
CoreCompactionSummarizerConfig,
|
||||
} from "../../types/config";
|
||||
import type { ProviderConfig } from "../../types/provider-settings";
|
||||
import {
|
||||
buildBudgetProjection,
|
||||
type BudgetProjectionResult,
|
||||
} from "./budget-projection";
|
||||
import {
|
||||
buildSummaryMessage,
|
||||
buildSummaryRequest,
|
||||
@@ -20,6 +24,43 @@ import {
|
||||
serializeConversation,
|
||||
} from "./compaction-shared";
|
||||
|
||||
const MIN_AGENTIC_SUMMARY_INPUT_TOKENS = 1_024;
|
||||
|
||||
function resolveProviderMaxInputTokens(
|
||||
providerConfig: ProviderConfig,
|
||||
): number | undefined {
|
||||
const explicit = providerConfig.maxInputTokens;
|
||||
if (typeof explicit === "number" && Number.isFinite(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
const modelInfoLimit =
|
||||
providerConfig.modelInfo?.maxInputTokens ??
|
||||
providerConfig.modelInfo?.contextWindow;
|
||||
if (typeof modelInfoLimit === "number" && Number.isFinite(modelInfoLimit)) {
|
||||
return modelInfoLimit;
|
||||
}
|
||||
const knownModelInfo = providerConfig.knownModels?.[providerConfig.modelId];
|
||||
const knownModelLimit =
|
||||
knownModelInfo?.maxInputTokens ?? knownModelInfo?.contextWindow;
|
||||
if (typeof knownModelLimit === "number" && Number.isFinite(knownModelLimit)) {
|
||||
return knownModelLimit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildAgenticSummaryInputBudget(options: {
|
||||
messages: CoreCompactionContext["messages"];
|
||||
targetTokens: number;
|
||||
estimateMessageTokens: EstimateMessageTokens;
|
||||
}): BudgetProjectionResult {
|
||||
return buildBudgetProjection({
|
||||
messages: options.messages,
|
||||
targetTokens: Math.max(1, options.targetTokens),
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
}
|
||||
|
||||
async function generateSummary(options: {
|
||||
providerConfig: ProviderConfig;
|
||||
request: string;
|
||||
@@ -93,7 +134,78 @@ export async function runAgenticCompaction(options: {
|
||||
}
|
||||
|
||||
const fileOps = extractFileOps(messagesToSummarize);
|
||||
const conversationText = serializeConversation(newMessagesToFold);
|
||||
const summarizerProviderConfig = resolveSummarizerConfig({
|
||||
activeProviderConfig: options.providerConfig,
|
||||
summarizer: options.summarizer,
|
||||
});
|
||||
const resolvedSummarizerInputLimit = resolveProviderMaxInputTokens(
|
||||
summarizerProviderConfig,
|
||||
);
|
||||
const canUseActiveContextLimit = options.summarizer === undefined;
|
||||
const activeCompactionInputLimit = Math.max(
|
||||
options.context.maxInputTokens,
|
||||
options.context.triggerTokens,
|
||||
MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
|
||||
);
|
||||
if (
|
||||
resolvedSummarizerInputLimit === undefined &&
|
||||
!canUseActiveContextLimit
|
||||
) {
|
||||
options.logger?.log(
|
||||
"Agentic compaction summarizer has no known input limit; using conservative summary budget",
|
||||
{
|
||||
severity: "warn",
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
fallbackInputLimit: MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
|
||||
},
|
||||
);
|
||||
}
|
||||
const summarizerInputLimit =
|
||||
resolvedSummarizerInputLimit ??
|
||||
(canUseActiveContextLimit
|
||||
? activeCompactionInputLimit
|
||||
: MIN_AGENTIC_SUMMARY_INPUT_TOKENS);
|
||||
const summaryRequestOverheadTokens = estimateTokens(
|
||||
buildSummaryRequest({
|
||||
previousSummary,
|
||||
conversationText: "",
|
||||
fileOps,
|
||||
}).length,
|
||||
);
|
||||
const availableSummaryInputTokens =
|
||||
summarizerInputLimit - summaryRequestOverheadTokens;
|
||||
if (availableSummaryInputTokens <= 0) {
|
||||
options.logger?.debug("Skipped agentic compaction: summarizer budget exhausted", {
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
summarizerInputLimit,
|
||||
summaryRequestOverheadTokens,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const summaryInputBudget = buildAgenticSummaryInputBudget({
|
||||
messages: newMessagesToFold,
|
||||
targetTokens: availableSummaryInputTokens,
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
if (summaryInputBudget.status === "failed") {
|
||||
options.logger?.log(
|
||||
"Skipped agentic compaction: summary input budget failed",
|
||||
{
|
||||
severity: "warn",
|
||||
budgetWarnings: summaryInputBudget.warnings.map(
|
||||
(warning) => warning.code,
|
||||
),
|
||||
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
|
||||
targetTokens: availableSummaryInputTokens,
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const conversationText = serializeConversation(summaryInputBudget.messages);
|
||||
const summaryRequest = buildSummaryRequest({
|
||||
previousSummary,
|
||||
conversationText,
|
||||
@@ -108,14 +220,20 @@ export async function runAgenticCompaction(options: {
|
||||
summaryRequestChars: summaryRequest.length,
|
||||
summaryRequestEstimatedTokens: estimateTokens(summaryRequest.length),
|
||||
newMessagesJsonChars: safeJsonSize(newMessagesToFold),
|
||||
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
|
||||
summaryInputActions: summaryInputBudget.actions.length,
|
||||
summaryInputWarnings: summaryInputBudget.warnings.map(
|
||||
(warning) => warning.code,
|
||||
),
|
||||
summaryRequestOverheadTokens,
|
||||
summarizerProviderId: summarizerProviderConfig.providerId,
|
||||
summarizerModelId: summarizerProviderConfig.modelId,
|
||||
summarizerInputLimit,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
triggerTokens: options.context.triggerTokens,
|
||||
});
|
||||
const rawSummary = await generateSummary({
|
||||
providerConfig: resolveSummarizerConfig({
|
||||
activeProviderConfig: options.providerConfig,
|
||||
summarizer: options.summarizer,
|
||||
}),
|
||||
providerConfig: summarizerProviderConfig,
|
||||
request: summaryRequest,
|
||||
logger: options.logger,
|
||||
});
|
||||
@@ -149,5 +267,13 @@ export async function runAgenticCompaction(options: {
|
||||
tokensAfter,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
return { messages: resultMessages };
|
||||
return {
|
||||
messages: resultMessages,
|
||||
budget: {
|
||||
policyIntent: "agentic_summary",
|
||||
actionCount: summaryInputBudget.actions.length,
|
||||
warningCount: summaryInputBudget.warnings.length,
|
||||
liveTailHandling: summaryInputBudget.liveTailHandling,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CoreCompactionContext,
|
||||
CoreCompactionResult,
|
||||
} from "../../types/config";
|
||||
import { buildBudgetProjection } from "./budget-projection";
|
||||
import {
|
||||
type EstimateMessageTokens,
|
||||
findFirstUserMessageIndex,
|
||||
@@ -390,7 +391,26 @@ export function runBasicCompaction(options: {
|
||||
...candidates.map((candidate) => candidate.message),
|
||||
...protectedTail,
|
||||
];
|
||||
if (!haveMessagesChanged(options.context.messages, nextMessages)) {
|
||||
const budgeted = buildBudgetProjection({
|
||||
messages: nextMessages,
|
||||
targetTokens,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: options.estimateMessageTokens,
|
||||
});
|
||||
// This final projection owns the hard output budget. Unlike the earlier
|
||||
// basic candidate passes, it may drop the original first-user message when
|
||||
// preserving the latest typed prompt and coherent tool closures requires it.
|
||||
if (budgeted.status === "failed") {
|
||||
options.logger?.debug("Basic compaction returned best-effort projection", {
|
||||
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
|
||||
projectedTokens: budgeted.estimatedTokens,
|
||||
targetTokens,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
}
|
||||
const resultMessages = budgeted.messages;
|
||||
|
||||
if (!haveMessagesChanged(options.context.messages, resultMessages)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -402,18 +422,29 @@ export function runBasicCompaction(options: {
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
const afterTokens = getTotalTokens(
|
||||
nextMessages,
|
||||
resultMessages,
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
options.logger?.debug("Performed basic compaction", {
|
||||
messagesBefore: options.context.messages.length,
|
||||
messagesAfter: nextMessages.length,
|
||||
messagesRemoved: options.context.messages.length - nextMessages.length,
|
||||
messagesAfter: resultMessages.length,
|
||||
messagesRemoved: options.context.messages.length - resultMessages.length,
|
||||
tokensBefore: beforeTokens,
|
||||
tokensAfter: afterTokens,
|
||||
budgetStatus: budgeted.status,
|
||||
budgetActions: budgeted.actions.length,
|
||||
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
|
||||
targetTokens,
|
||||
maxInputTokens: options.context.maxInputTokens,
|
||||
});
|
||||
|
||||
return { messages: nextMessages };
|
||||
return {
|
||||
messages: resultMessages,
|
||||
budget: {
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: budgeted.actions.length,
|
||||
warningCount: budgeted.warnings.length,
|
||||
liveTailHandling: budgeted.liveTailHandling,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
export type {
|
||||
BlockBudgetClass,
|
||||
BudgetAction,
|
||||
BudgetActionKind,
|
||||
BudgetActionReason,
|
||||
BudgetPath,
|
||||
BudgetPolicyIntent,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
ContentBlockBudgetClassification,
|
||||
LiveTailHandling,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBudgetProjection,
|
||||
findLatestTypedUserMessageIndex,
|
||||
} from "./project";
|
||||
|
||||
const estimateChars = (message: MessageWithMetadata) =>
|
||||
JSON.stringify(message).length;
|
||||
|
||||
describe("buildBudgetProjection", () => {
|
||||
it("fails explicitly for impossible budgets", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [{ role: "user", content: "keep me" }],
|
||||
targetTokens: 0,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.warnings[0]?.code).toBe("budget_impossible");
|
||||
});
|
||||
|
||||
it("drops unsafe image and redacted thinking blocks instead of truncating them", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "old context" },
|
||||
{
|
||||
type: "redacted_thinking",
|
||||
data: "x".repeat(500),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: "y".repeat(500),
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 150,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).not.toContain("redacted_thinking");
|
||||
expect(serialized).not.toContain("image/png");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.liveTailHandling).toBe("included_degraded");
|
||||
});
|
||||
|
||||
it("keeps unsafe blocks when input is already under budget", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look at this" },
|
||||
{
|
||||
type: "image",
|
||||
data: "small-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.actions).toEqual([]);
|
||||
expect(result.liveTailHandling).toBe("included_verbatim");
|
||||
expect(JSON.stringify(result.messages)).toContain("small-image");
|
||||
});
|
||||
|
||||
it("preserves unsafe blocks in the latest typed user message", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
data: "live-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 120,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result.messages)).toContain("live-image");
|
||||
expect(result.actions).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "dropped_block" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("protects latest typed user after thinking-only messages are pruned", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "discard me" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what is in this image?" },
|
||||
{
|
||||
type: "image",
|
||||
data: "live-image",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("live-image");
|
||||
expect(serialized).not.toContain("discard me");
|
||||
});
|
||||
|
||||
it("keeps tool-use and tool-result pairs coherent when dropping history", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "original task" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "read_files",
|
||||
input: { file_paths: ["/tmp/a.ts"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read_files",
|
||||
content: "x".repeat(1000),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 140,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).not.toContain("tool_1");
|
||||
expect(serialized).toContain("latest task");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "tool_pair_boundary" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("records budget action paths against original message indexes", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "x", mediaType: "image/png" }],
|
||||
},
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "assistant", content: "old answer " + "y".repeat(500) },
|
||||
{ role: "user", content: "latest task" },
|
||||
],
|
||||
targetTokens: 80,
|
||||
policyIntent: "basic_compaction_projection",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
path: expect.objectContaining({ messageIndex: 1 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dropped_message",
|
||||
path: expect.objectContaining({ messageIndex: 2 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects the latest typed user message when tool results follow it", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "old task" },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_1", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read",
|
||||
content: "result",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findLatestTypedUserMessageIndex(messages)).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves the latest typed prompt under pressure", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "old task " + "x".repeat(500) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
name: "read",
|
||||
content: "result " + "y".repeat(500),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 120,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(result.messages)).toContain("latest typed prompt");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: "protected_live_tail" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not preserve later text or file blocks after tool-result budget is exhausted", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool_live", name: "read", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_live",
|
||||
name: "read",
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(200) },
|
||||
{ type: "file", path: "/tmp/huge.txt", content: "b".repeat(1_000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 260,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("latest typed prompt");
|
||||
expect(serialized).not.toContain("b".repeat(100));
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "truncated_text",
|
||||
reason: "over_budget",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops thinking blocks instead of mutating provider-native reasoning", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "a".repeat(1_000) },
|
||||
{ type: "thinking", thinking: "b".repeat(1_000) },
|
||||
],
|
||||
},
|
||||
],
|
||||
targetTokens: 190,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const assistant = result.messages.find(
|
||||
(message) => message.role === "assistant",
|
||||
);
|
||||
expect(JSON.stringify(assistant)).not.toContain("b".repeat(100));
|
||||
expect(JSON.stringify(assistant)).not.toContain("\"thinking\"");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops nested unsafe tool-result blocks outside the protected tail", () => {
|
||||
const result = buildBudgetProjection({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_old",
|
||||
name: "read",
|
||||
content: [
|
||||
{ type: "text", text: "old output" },
|
||||
{
|
||||
type: "image",
|
||||
data: "old-image-data",
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
],
|
||||
targetTokens: 1_000,
|
||||
policyIntent: "agentic_summary",
|
||||
estimateMessageTokens: estimateChars,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(result.messages);
|
||||
expect(serialized).toContain("old output");
|
||||
expect(serialized).not.toContain("old-image-data");
|
||||
expect(result.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dropped_block",
|
||||
reason: "unsafe_to_truncate",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
import type {
|
||||
ContentBlock,
|
||||
MessageWithMetadata,
|
||||
ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
BudgetAction,
|
||||
BudgetProjectionOptions,
|
||||
BudgetProjectionResult,
|
||||
BudgetProjectionWarning,
|
||||
BudgetPolicyIntent,
|
||||
} from "./types";
|
||||
|
||||
type EstimateMessageTokens = (message: MessageWithMetadata) => number;
|
||||
|
||||
interface ProjectionPolicy {
|
||||
protectLatestTypedUser: boolean;
|
||||
protectLiveTailFromDrop: boolean;
|
||||
dropUnsafeOutsideLiveTail: boolean;
|
||||
dropThinkingBlocks: boolean;
|
||||
}
|
||||
|
||||
function resolveProjectionPolicy(
|
||||
intent: BudgetPolicyIntent,
|
||||
): ProjectionPolicy {
|
||||
switch (intent) {
|
||||
case "agentic_summary":
|
||||
case "basic_compaction_projection":
|
||||
return {
|
||||
protectLatestTypedUser: true,
|
||||
protectLiveTailFromDrop: true,
|
||||
dropUnsafeOutsideLiveTail: true,
|
||||
dropThinkingBlocks: true,
|
||||
};
|
||||
case "normal_provider_request":
|
||||
return {
|
||||
protectLatestTypedUser: true,
|
||||
protectLiveTailFromDrop: true,
|
||||
dropUnsafeOutsideLiveTail: false,
|
||||
dropThinkingBlocks: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function cloneMessages(messages: MessageWithMetadata[]): MessageWithMetadata[] {
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
content: Array.isArray(message.content)
|
||||
? message.content.map((block) => ({ ...block }) as ContentBlock)
|
||||
: message.content,
|
||||
...(message.metadata ? { metadata: { ...message.metadata } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function safeJsonSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
} catch {
|
||||
return String(value).length;
|
||||
}
|
||||
}
|
||||
|
||||
function totalTokens(
|
||||
messages: MessageWithMetadata[],
|
||||
estimateMessageTokens: EstimateMessageTokens,
|
||||
): number {
|
||||
return messages.reduce(
|
||||
(total, message) => total + estimateMessageTokens(message),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function isToolResultOnlyUserMessage(message: MessageWithMetadata): boolean {
|
||||
return (
|
||||
message.role === "user" &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.length > 0 &&
|
||||
message.content.every((block) => block.type === "tool_result")
|
||||
);
|
||||
}
|
||||
|
||||
export function findLatestTypedUserMessageIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collectToolIds(message: MessageWithMetadata): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!Array.isArray(message.content)) {
|
||||
return ids;
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use") {
|
||||
ids.add(block.id);
|
||||
} else if (block.type === "tool_result") {
|
||||
ids.add(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function buildToolPairIndex(
|
||||
messages: MessageWithMetadata[],
|
||||
): Map<string, Set<number>> {
|
||||
const index = new Map<string, Set<number>>();
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
for (const id of collectToolIds(messages[messageIndex])) {
|
||||
const existing = index.get(id);
|
||||
if (existing) {
|
||||
existing.add(messageIndex);
|
||||
} else {
|
||||
index.set(id, new Set([messageIndex]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function collectMessageClosure(
|
||||
messages: MessageWithMetadata[],
|
||||
startIndex: number,
|
||||
): Set<number> {
|
||||
const pairIndex = buildToolPairIndex(messages);
|
||||
const removal = new Set<number>();
|
||||
const queue = [startIndex];
|
||||
while (queue.length > 0) {
|
||||
const index = queue.shift();
|
||||
if (index === undefined || removal.has(index)) {
|
||||
continue;
|
||||
}
|
||||
removal.add(index);
|
||||
for (const id of collectToolIds(messages[index])) {
|
||||
for (const linked of pairIndex.get(id) ?? []) {
|
||||
if (!removal.has(linked)) {
|
||||
queue.push(linked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removal;
|
||||
}
|
||||
|
||||
function isUnsafeBlock(block: ContentBlock): boolean {
|
||||
return block.type === "image" || block.type === "redacted_thinking";
|
||||
}
|
||||
|
||||
function isNestedUnsafeToolResultBlock(
|
||||
block: Extract<ToolResultContent["content"], unknown[]>[number],
|
||||
): boolean {
|
||||
return block.type === "image";
|
||||
}
|
||||
|
||||
function shouldDropWholeBlock(
|
||||
block: ContentBlock,
|
||||
policy: ProjectionPolicy,
|
||||
isProtected: boolean,
|
||||
): boolean {
|
||||
if (policy.dropThinkingBlocks && block.type === "thinking") {
|
||||
return true;
|
||||
}
|
||||
return policy.dropUnsafeOutsideLiveTail && !isProtected && isUnsafeBlock(block);
|
||||
}
|
||||
|
||||
function pruneEmptyMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
|
||||
const next: MessageWithMetadata[] = [];
|
||||
const nextOriginalIndexes: number[] = [];
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
if (Array.isArray(message.content) && message.content.length === 0) {
|
||||
actions.push({
|
||||
kind: "dropped_message",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "over_budget",
|
||||
originalSize: safeJsonSize(message),
|
||||
finalSize: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push(message);
|
||||
nextOriginalIndexes.push(originalIndexes[index]);
|
||||
}
|
||||
return { messages: next, originalIndexes: nextOriginalIndexes };
|
||||
}
|
||||
|
||||
function dropUnsafeBlocks(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
protectedStartIndex: number,
|
||||
policy: ProjectionPolicy,
|
||||
): MessageWithMetadata[] {
|
||||
return messages.map((message, messageIndex) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
let changed = false;
|
||||
const protectedBlock =
|
||||
protectedStartIndex >= 0 && messageIndex >= protectedStartIndex;
|
||||
const content = message.content.flatMap((block, blockIndex) => {
|
||||
if (shouldDropWholeBlock(block, policy, protectedBlock)) {
|
||||
changed = true;
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: 0,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
if (block.type === "tool_result" && Array.isArray(block.content)) {
|
||||
const nestedContent = block.content.filter((nestedBlock) => {
|
||||
if (
|
||||
policy.dropUnsafeOutsideLiveTail &&
|
||||
!protectedBlock &&
|
||||
isNestedUnsafeToolResultBlock(nestedBlock)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (nestedContent.length !== block.content.length) {
|
||||
changed = true;
|
||||
const nextBlock = { ...block, content: nestedContent };
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: safeJsonSize(nextBlock),
|
||||
});
|
||||
return [nextBlock];
|
||||
}
|
||||
}
|
||||
if (!isUnsafeBlock(block)) {
|
||||
return [block];
|
||||
}
|
||||
if (protectedBlock) {
|
||||
return [block];
|
||||
}
|
||||
return [block];
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
});
|
||||
}
|
||||
|
||||
function dropThinkingBlocks(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
actions: BudgetAction[],
|
||||
): MessageWithMetadata[] {
|
||||
return messages.map((message, messageIndex) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
let changed = false;
|
||||
const content = message.content.filter((block, blockIndex) => {
|
||||
if (block.type !== "thinking") {
|
||||
return true;
|
||||
}
|
||||
changed = true;
|
||||
actions.push({
|
||||
kind: "dropped_block",
|
||||
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
|
||||
reason: "unsafe_to_truncate",
|
||||
originalSize: safeJsonSize(block),
|
||||
finalSize: 0,
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return changed ? { ...message, content } : message;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function truncateText(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
if (maxChars <= 16) {
|
||||
return text.slice(0, Math.max(1, maxChars));
|
||||
}
|
||||
const estimateMarker = `\n...[truncated ${text.length - maxChars} chars]`;
|
||||
const keep = Math.max(1, maxChars - estimateMarker.length);
|
||||
const marker = `\n...[truncated ${text.length - keep} chars]`;
|
||||
return `${text.slice(0, keep)}${marker}`;
|
||||
}
|
||||
|
||||
function truncateToolResultContent(
|
||||
content: ToolResultContent["content"],
|
||||
maxChars: number,
|
||||
): ToolResultContent["content"] {
|
||||
if (typeof content === "string") {
|
||||
return truncateText(content, maxChars);
|
||||
}
|
||||
let remaining = maxChars;
|
||||
return content.map((block) => {
|
||||
if (remaining <= 0) {
|
||||
if (block.type === "text") {
|
||||
return { ...block, text: "" };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return { ...block, content: "" };
|
||||
}
|
||||
return block;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
const text = truncateText(block.text, remaining);
|
||||
remaining -= text.length;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
const content = truncateText(block.content, remaining);
|
||||
remaining -= content.length;
|
||||
return { ...block, content };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
||||
function toolResultTextLength(content: ToolResultContent["content"]): number {
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
return content.reduce((total, block) => {
|
||||
if (block.type === "text") {
|
||||
return total + block.text.length;
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return total + block.content.length;
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function truncateMessageText(
|
||||
message: MessageWithMetadata,
|
||||
maxChars: number,
|
||||
): MessageWithMetadata {
|
||||
if (typeof message.content === "string") {
|
||||
return { ...message, content: truncateText(message.content, maxChars) };
|
||||
}
|
||||
let remaining = maxChars;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((block) => {
|
||||
if (remaining <= 0) {
|
||||
if (block.type === "text") {
|
||||
return { ...block, text: "" };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
return { ...block, content: "" };
|
||||
}
|
||||
return block;
|
||||
}
|
||||
if (block.type === "text") {
|
||||
const text = truncateText(block.text, remaining);
|
||||
remaining -= text.length;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "file") {
|
||||
const content = truncateText(block.content, remaining);
|
||||
remaining -= content.length;
|
||||
return { ...block, content };
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
const content = truncateToolResultContent(block.content, remaining);
|
||||
remaining -= toolResultTextLength(content);
|
||||
return { ...block, content };
|
||||
}
|
||||
return block;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function hasTruncatableText(message: MessageWithMetadata): boolean {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.length > 0;
|
||||
}
|
||||
return message.content.some(
|
||||
(block) =>
|
||||
block.type === "text" ||
|
||||
block.type === "file" ||
|
||||
block.type === "tool_result",
|
||||
);
|
||||
}
|
||||
|
||||
function removeMessagesAt(
|
||||
messages: MessageWithMetadata[],
|
||||
originalIndexes: number[],
|
||||
removal: Set<number>,
|
||||
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
|
||||
return {
|
||||
messages: messages.filter((_, index) => !removal.has(index)),
|
||||
originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)),
|
||||
};
|
||||
}
|
||||
|
||||
function closureTouchesProtectedTail(
|
||||
closure: Set<number>,
|
||||
protectedStartIndex: number,
|
||||
): boolean {
|
||||
if (protectedStartIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
for (const removalIndex of closure) {
|
||||
if (removalIndex >= protectedStartIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildBudgetProjection(
|
||||
options: BudgetProjectionOptions,
|
||||
): BudgetProjectionResult {
|
||||
const actions: BudgetAction[] = [];
|
||||
const warnings: BudgetProjectionWarning[] = [];
|
||||
const policy = resolveProjectionPolicy(options.policyIntent);
|
||||
if (options.targetTokens <= 0) {
|
||||
return {
|
||||
status: "failed",
|
||||
messages: cloneMessages(options.messages),
|
||||
actions,
|
||||
liveTailHandling: "preserved_out_of_band",
|
||||
estimatedTokens: totalTokens(
|
||||
options.messages,
|
||||
options.estimateMessageTokens,
|
||||
),
|
||||
warnings: [
|
||||
{
|
||||
code: "budget_impossible",
|
||||
message: "Target budget must be greater than zero.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let messages = cloneMessages(options.messages);
|
||||
let originalIndexes = messages.map((_, index) => index);
|
||||
if (policy.dropThinkingBlocks) {
|
||||
const prunedThinking = pruneEmptyMessages(
|
||||
dropThinkingBlocks(messages, originalIndexes, actions),
|
||||
originalIndexes,
|
||||
actions,
|
||||
);
|
||||
messages = prunedThinking.messages;
|
||||
originalIndexes = prunedThinking.originalIndexes;
|
||||
}
|
||||
const protectedStartIndex = policy.protectLatestTypedUser
|
||||
? findLatestTypedUserMessageIndex(messages)
|
||||
: -1;
|
||||
if (policy.dropUnsafeOutsideLiveTail) {
|
||||
const prunedUnsafe = pruneEmptyMessages(
|
||||
dropUnsafeBlocks(
|
||||
messages,
|
||||
originalIndexes,
|
||||
actions,
|
||||
protectedStartIndex,
|
||||
policy,
|
||||
),
|
||||
originalIndexes,
|
||||
actions,
|
||||
);
|
||||
messages = prunedUnsafe.messages;
|
||||
originalIndexes = prunedUnsafe.originalIndexes;
|
||||
}
|
||||
let estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
if (estimatedTokens <= options.targetTokens) {
|
||||
return {
|
||||
status: "ok",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling:
|
||||
actions.length > 0 ? "included_degraded" : "included_verbatim",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
for (
|
||||
let index = messages.length - 1;
|
||||
index >= 0 && estimatedTokens > options.targetTokens;
|
||||
index -= 1
|
||||
) {
|
||||
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
|
||||
if (index === latestTypedUserIndex) {
|
||||
continue;
|
||||
}
|
||||
if (!hasTruncatableText(messages[index])) {
|
||||
continue;
|
||||
}
|
||||
const originalSize = safeJsonSize(messages[index]);
|
||||
const charsPerToken = Math.max(
|
||||
1,
|
||||
originalSize /
|
||||
Math.max(1, options.estimateMessageTokens(messages[index])),
|
||||
);
|
||||
const targetChars = Math.max(
|
||||
16,
|
||||
Math.floor(
|
||||
(options.targetTokens * charsPerToken) /
|
||||
Math.max(1, messages.length),
|
||||
),
|
||||
);
|
||||
messages[index] = truncateMessageText(messages[index], targetChars);
|
||||
actions.push({
|
||||
kind: "truncated_text",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "over_budget",
|
||||
originalSize,
|
||||
finalSize: safeJsonSize(messages[index]),
|
||||
});
|
||||
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
}
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < messages.length && estimatedTokens > options.targetTokens;
|
||||
) {
|
||||
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
|
||||
const protectedStartIndex = policy.protectLiveTailFromDrop
|
||||
? latestTypedUserIndex
|
||||
: -1;
|
||||
if (index === latestTypedUserIndex) {
|
||||
actions.push({
|
||||
kind: "preserved",
|
||||
path: { messageIndex: originalIndexes[index] },
|
||||
reason: "protected_live_tail",
|
||||
originalSize: safeJsonSize(messages[index]),
|
||||
finalSize: safeJsonSize(messages[index]),
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const closure = collectMessageClosure(messages, index);
|
||||
if (closureTouchesProtectedTail(closure, protectedStartIndex)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
for (const removalIndex of closure) {
|
||||
actions.push({
|
||||
kind: "dropped_message",
|
||||
path: { messageIndex: originalIndexes[removalIndex] },
|
||||
reason:
|
||||
closure.size > 1 || collectToolIds(messages[removalIndex]).size > 0
|
||||
? "tool_pair_boundary"
|
||||
: "over_budget",
|
||||
originalSize: safeJsonSize(messages[removalIndex]),
|
||||
finalSize: 0,
|
||||
});
|
||||
}
|
||||
const removed = removeMessagesAt(messages, originalIndexes, closure);
|
||||
messages = removed.messages;
|
||||
originalIndexes = removed.originalIndexes;
|
||||
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
|
||||
}
|
||||
|
||||
if (estimatedTokens > options.targetTokens) {
|
||||
warnings.push({
|
||||
code: "budget_unachievable_with_protections",
|
||||
message:
|
||||
"Projection could not reach budget without violating protected content.",
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling: "included_degraded",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
messages,
|
||||
actions,
|
||||
liveTailHandling:
|
||||
actions.length > 0 ? "included_degraded" : "included_verbatim",
|
||||
estimatedTokens,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ContentBlock, MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
export type BudgetPolicyIntent =
|
||||
| "agentic_summary"
|
||||
| "basic_compaction_projection"
|
||||
| "normal_provider_request";
|
||||
|
||||
export type BudgetActionKind =
|
||||
| "truncated_text"
|
||||
| "dropped_block"
|
||||
| "dropped_message"
|
||||
| "preserved";
|
||||
|
||||
export type BudgetActionReason =
|
||||
| "over_budget"
|
||||
| "unsafe_to_truncate"
|
||||
| "tool_pair_boundary"
|
||||
| "protected_live_tail";
|
||||
|
||||
export type LiveTailHandling =
|
||||
| "included_verbatim"
|
||||
| "included_degraded"
|
||||
| "summarized_as_context"
|
||||
| "omitted_with_warning"
|
||||
| "preserved_out_of_band";
|
||||
|
||||
export type BlockBudgetClass =
|
||||
| "text"
|
||||
| "thinking"
|
||||
| "tool_use"
|
||||
| "tool_result"
|
||||
| "unsafe_binary"
|
||||
| "unsafe_encrypted"
|
||||
| "opaque";
|
||||
|
||||
export interface BudgetPath {
|
||||
messageIndex: number;
|
||||
blockIndex?: number;
|
||||
}
|
||||
|
||||
interface BaseBudgetAction {
|
||||
path: BudgetPath;
|
||||
originalSize: number;
|
||||
finalSize: number;
|
||||
}
|
||||
|
||||
export type BudgetMutationAction =
|
||||
| (BaseBudgetAction & {
|
||||
kind: "truncated_text";
|
||||
reason: Extract<BudgetActionReason, "over_budget">;
|
||||
})
|
||||
| (BaseBudgetAction & {
|
||||
kind: "dropped_block" | "dropped_message";
|
||||
reason: Exclude<BudgetActionReason, "protected_live_tail">;
|
||||
});
|
||||
|
||||
export interface BudgetPreservedAction extends BaseBudgetAction {
|
||||
kind: "preserved";
|
||||
reason: Extract<
|
||||
BudgetActionReason,
|
||||
"protected_live_tail" | "tool_pair_boundary"
|
||||
>;
|
||||
}
|
||||
|
||||
export type BudgetAction = BudgetMutationAction | BudgetPreservedAction;
|
||||
|
||||
export type BudgetProjectionWarningCode =
|
||||
| "budget_impossible"
|
||||
| "budget_unachievable_with_protections";
|
||||
|
||||
export interface BudgetProjectionWarning {
|
||||
code: BudgetProjectionWarningCode;
|
||||
message: string;
|
||||
path?: BudgetPath;
|
||||
}
|
||||
|
||||
export interface BudgetProjectionOptions {
|
||||
messages: MessageWithMetadata[];
|
||||
targetTokens: number;
|
||||
policyIntent: BudgetPolicyIntent;
|
||||
estimateMessageTokens: (message: MessageWithMetadata) => number;
|
||||
}
|
||||
|
||||
export interface BudgetProjectionResult {
|
||||
status: "ok" | "failed";
|
||||
messages: MessageWithMetadata[];
|
||||
actions: BudgetAction[];
|
||||
liveTailHandling: LiveTailHandling;
|
||||
estimatedTokens: number;
|
||||
warnings: BudgetProjectionWarning[];
|
||||
}
|
||||
|
||||
export interface ContentBlockBudgetClassification {
|
||||
block: ContentBlock;
|
||||
budgetClass: BlockBudgetClass;
|
||||
canStringTruncate: boolean;
|
||||
canDropWholeBlock: boolean;
|
||||
}
|
||||
@@ -480,6 +480,7 @@ export function resolveSummarizerConfig(options: {
|
||||
apiKey: summarizer.apiKey ?? baseProviderConfig?.apiKey,
|
||||
baseUrl: summarizer.baseUrl ?? baseProviderConfig?.baseUrl,
|
||||
headers: summarizer.headers ?? baseProviderConfig?.headers,
|
||||
modelInfo: summarizer.modelInfo ?? baseProviderConfig?.modelInfo,
|
||||
knownModels: summarizer.knownModels ?? baseProviderConfig?.knownModels,
|
||||
maxOutputTokens:
|
||||
summarizer.maxOutputTokens ?? DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreCompactionContext } from "../../types/config";
|
||||
import { buildAgenticSummaryInputBudget } from "./agentic-compaction";
|
||||
import { runBasicCompaction } from "./basic-compaction";
|
||||
import { createContextCompactionPrepareTurn } from "./compaction";
|
||||
import {
|
||||
createTokenEstimator,
|
||||
estimateTokens,
|
||||
resolveSummarizerConfig,
|
||||
serializeMessage,
|
||||
TOOL_RESULT_CHAR_LIMIT,
|
||||
@@ -359,13 +361,28 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
const compacted = runForcedBasicCompaction(messages, 1);
|
||||
|
||||
expect(compacted).toEqual([
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "user", content: "Read the latest file" },
|
||||
assistantToolUseMessage("tool-a"),
|
||||
toolResultMessage("tool-a", "latest result"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("budgets the complete basic compaction output including the latest turn", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "original task" },
|
||||
{ role: "assistant", content: "old assistant " + "x".repeat(10_000) },
|
||||
{ role: "user", content: "latest typed prompt" },
|
||||
assistantToolUseMessage("tool-live"),
|
||||
toolResultMessage("tool-live", "live result " + "y".repeat(10_000)),
|
||||
];
|
||||
|
||||
const compacted = runForcedBasicCompaction(messages, 700);
|
||||
|
||||
expect(totalJsonTokens(compacted)).toBeLessThanOrEqual(700);
|
||||
expect(JSON.stringify(compacted)).toContain("latest typed prompt");
|
||||
expectNoOrphanedToolPairs(compacted);
|
||||
});
|
||||
|
||||
it("does not compact a single typed user message", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "Only current request" },
|
||||
@@ -396,6 +413,23 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(anthropicConfig.maxOutputTokens).toBe(1_024);
|
||||
});
|
||||
|
||||
it("preserves summarizer modelInfo without a nested providerConfig", () => {
|
||||
const resolved = resolveSummarizerConfig({
|
||||
activeProviderConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
modelInfo: { id: "primary-model", maxInputTokens: 100_000 },
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
summarizer: {
|
||||
providerId: "openai",
|
||||
modelId: "small-summary",
|
||||
modelInfo: { id: "small-summary", maxInputTokens: 600 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.modelInfo?.maxInputTokens).toBe(600);
|
||||
});
|
||||
|
||||
it("summarizes older messages and keeps recent messages", async () => {
|
||||
const emitStatusNotice = vi.fn();
|
||||
createHandlerMock.mockReturnValue({
|
||||
@@ -648,6 +682,43 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(summarizerPrompt.length).toBeLessThan(longToolOutput.length);
|
||||
});
|
||||
|
||||
it("budgets agentic summary input before serialization", () => {
|
||||
const result = buildAgenticSummaryInputBudget({
|
||||
messages: [
|
||||
{ role: "user", content: "Run a large command" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-large",
|
||||
name: "execute_command",
|
||||
input: { command: "print-large-output" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-large",
|
||||
name: "execute_command",
|
||||
content: "x".repeat(50_000),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Latest typed prompt" },
|
||||
],
|
||||
targetTokens: 400,
|
||||
estimateMessageTokens: estimateJsonTokens,
|
||||
});
|
||||
|
||||
expect(result.estimatedTokens).toBeLessThanOrEqual(400);
|
||||
expect(JSON.stringify(result.messages)).toContain("Latest typed prompt");
|
||||
expect(result.actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never lands the agentic cut in the middle of a tool pair", async () => {
|
||||
// Repro for the "No tool call found for function call output" provider
|
||||
// error: findCutIndex used to walk back by token budget and could land
|
||||
@@ -822,6 +893,79 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("budgets agentic summary input against the configured summarizer context window", async () => {
|
||||
let summaryRequest = "";
|
||||
createHandlerMock.mockReturnValue({
|
||||
createMessage: vi.fn((_system: string, messages: LlmsProviders.Message[]) => {
|
||||
summaryRequest = String(messages[0]?.content ?? "");
|
||||
return streamChunks([
|
||||
{ type: "text", id: "summary-small", text: "## Goal\nSummarized" },
|
||||
{ type: "done", id: "summary-small", success: true },
|
||||
]);
|
||||
}),
|
||||
});
|
||||
|
||||
const summarizerLimit = 600;
|
||||
const oversizedAssistant = "assistant details ".repeat(5_000);
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
providerConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "primary-model",
|
||||
modelInfo: { id: "primary-model", maxInputTokens: 10_000 },
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
preserveRecentTokens: 1,
|
||||
reserveTokens: 5,
|
||||
summarizer: {
|
||||
providerId: "openai",
|
||||
modelId: "small-summary",
|
||||
modelInfo: {
|
||||
id: "small-summary",
|
||||
maxInputTokens: summarizerLimit,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages: [
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "assistant", content: oversizedAssistant },
|
||||
{ role: "user", content: "Latest turn" },
|
||||
{ role: "assistant", content: "Latest answer" },
|
||||
],
|
||||
apiMessages: [
|
||||
{ role: "user", content: "Old request" },
|
||||
{ role: "assistant", content: oversizedAssistant },
|
||||
{ role: "user", content: "Latest turn" },
|
||||
{ role: "assistant", content: "Latest answer" },
|
||||
],
|
||||
model: {
|
||||
id: "primary-model",
|
||||
provider: "anthropic",
|
||||
info: { id: "primary-model", maxInputTokens: 10_000 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(createHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(estimateTokens(summaryRequest.length)).toBeLessThanOrEqual(
|
||||
summarizerLimit,
|
||||
);
|
||||
expect(summaryRequest).not.toContain(oversizedAssistant);
|
||||
});
|
||||
|
||||
it("uses basic compaction without calling the summarizer", async () => {
|
||||
const emitStatusNotice = vi.fn();
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
@@ -1311,7 +1455,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result?.messages.length).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it("preserves user image blocks during basic compaction sanitization", () => {
|
||||
it("drops old user image blocks during basic compaction sanitization", () => {
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
@@ -1347,7 +1491,6 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result?.messages).toBeDefined();
|
||||
expect(result?.messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Older user turn" },
|
||||
{ type: "image", data: "abc", mediaType: "image/png" },
|
||||
]);
|
||||
expect(result?.messages.at(-1)).toEqual({
|
||||
role: "user",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import {
|
||||
captureCompactionBudgetEmergency,
|
||||
captureCompactionExecuted,
|
||||
captureCompactionSkipped,
|
||||
type TelemetryCompactionStrategy,
|
||||
} from "../../services/telemetry/core-events";
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "../../session/models/session-compaction";
|
||||
import type {
|
||||
CoreCompactionConfig,
|
||||
CoreCompactionContext,
|
||||
@@ -43,6 +49,10 @@ export interface ContextPipelinePrepareTurnResult {
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export type ContextPipelinePrepareTurn = (
|
||||
context: ContextPipelinePrepareTurnInput,
|
||||
) => Promise<ContextPipelinePrepareTurnResult | undefined>;
|
||||
|
||||
type EstimateMessageTokens = ReturnType<typeof createTokenEstimator>;
|
||||
|
||||
type BuiltinCompactionStrategyOptions = {
|
||||
@@ -408,6 +418,31 @@ export function createContextCompactionPrepareTurn(
|
||||
modelId: config.modelId,
|
||||
...telemetryIdentity,
|
||||
});
|
||||
if (
|
||||
result.budget &&
|
||||
(result.budget.actionCount > 0 || result.budget.warningCount > 0)
|
||||
) {
|
||||
captureCompactionBudgetEmergency(config.telemetry, {
|
||||
ulid: telemetryUlid,
|
||||
strategy: telemetryStrategy,
|
||||
mode,
|
||||
policyIntent: result.budget.policyIntent,
|
||||
actionCount: result.budget.actionCount,
|
||||
warningCount: result.budget.warningCount,
|
||||
liveTailHandling: result.budget.liveTailHandling,
|
||||
provider: config.providerId,
|
||||
modelId: config.modelId,
|
||||
...telemetryIdentity,
|
||||
});
|
||||
context.emitStatusNotice?.("compaction-budget-adjusted", {
|
||||
kind: "compaction_budget_emergency",
|
||||
reason: "compaction_budget_emergency",
|
||||
iteration: context.iteration,
|
||||
policyIntent: result.budget.policyIntent,
|
||||
actionCount: result.budget.actionCount,
|
||||
warningCount: result.budget.warningCount,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
captureCompactionSkipped(config.telemetry, {
|
||||
ulid: telemetryUlid,
|
||||
@@ -428,3 +463,67 @@ export function createContextCompactionPrepareTurn(
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompactionStateAwarePrepareTurn(input: {
|
||||
compact?: ContextPipelinePrepareTurn;
|
||||
getState?: () => SessionCompactionState | undefined;
|
||||
saveState?: (state: SessionCompactionState) => void | Promise<void>;
|
||||
clearState?: () => void | Promise<void>;
|
||||
}): ContextPipelinePrepareTurn {
|
||||
return async (context) => {
|
||||
const existingState = input.getState?.();
|
||||
const projectedMessages = existingState
|
||||
? projectSessionCompactionState(existingState, context.messages)
|
||||
: undefined;
|
||||
if (existingState && projectedMessages) {
|
||||
// Re-compaction intentionally starts from the compacted projection plus
|
||||
// canonical tail. This keeps automatic turns bounded without rebuilding a
|
||||
// full-transcript summary every turn; manual `/compact` is the path for a
|
||||
// fresh summary from canonical history.
|
||||
const result = input.compact
|
||||
? await input.compact({
|
||||
...context,
|
||||
messages: projectedMessages,
|
||||
apiMessages: projectedMessages,
|
||||
})
|
||||
: undefined;
|
||||
if (result?.messages) {
|
||||
const systemPrompt = result.systemPrompt ?? existingState.system_prompt;
|
||||
const nextState = createSessionCompactionState({
|
||||
sourceMessages: context.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: context.conversationId,
|
||||
systemPrompt,
|
||||
});
|
||||
await input.saveState?.(nextState);
|
||||
return {
|
||||
...result,
|
||||
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: projectedMessages,
|
||||
...(result?.systemPrompt !== undefined
|
||||
? { systemPrompt: result.systemPrompt }
|
||||
: existingState.system_prompt !== undefined
|
||||
? { systemPrompt: existingState.system_prompt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (existingState) {
|
||||
await input.clearState?.();
|
||||
}
|
||||
|
||||
const result = input.compact ? await input.compact(context) : undefined;
|
||||
if (result?.messages) {
|
||||
const nextState = createSessionCompactionState({
|
||||
sourceMessages: context.messages,
|
||||
compactedMessages: result.messages,
|
||||
conversationId: context.conversationId,
|
||||
systemPrompt: result.systemPrompt,
|
||||
});
|
||||
await input.saveState?.(nextState);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ import type {
|
||||
} from "../../runtime/host/runtime-host";
|
||||
import { isSessionNotFoundError } from "../../runtime/host/runtime-host";
|
||||
import { RuntimeHostEventBus } from "../../runtime/host/runtime-host-support";
|
||||
import {
|
||||
parseSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "../../session/models/session-compaction";
|
||||
import {
|
||||
type SessionManifest,
|
||||
SessionManifestSchema,
|
||||
@@ -821,6 +825,9 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
input.toolPolicies as Record<string, unknown> | undefined,
|
||||
),
|
||||
initialMessages: input.initialMessages,
|
||||
...(input.initialCompactionState
|
||||
? { initialCompactionState: input.initialCompactionState }
|
||||
: {}),
|
||||
});
|
||||
this.registerPlannedSession(
|
||||
plannedSessionId,
|
||||
@@ -958,6 +965,12 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
),
|
||||
...(startConfig.initialCompactionState
|
||||
? {
|
||||
initialCompactionState:
|
||||
startConfig.initialCompactionState,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -1277,6 +1290,38 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
return { updated: reply.ok };
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return { updated: false };
|
||||
const reply = await this.client.command(
|
||||
"session.compaction.update",
|
||||
{ sessionId: target, state },
|
||||
target,
|
||||
);
|
||||
return {
|
||||
updated: reply.ok && reply.payload?.updated === true,
|
||||
};
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return undefined;
|
||||
const reply = await this.client.command(
|
||||
"session.compaction.get",
|
||||
{ sessionId: target },
|
||||
target,
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(hubReplyErrorMessage(reply, "session.compaction.get"));
|
||||
}
|
||||
return parseSessionCompactionState(reply.payload?.state);
|
||||
}
|
||||
|
||||
async readSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<import("@cline/llms").Message[]> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type StartSessionInput,
|
||||
type StartSessionResult,
|
||||
} from "../../runtime/host/runtime-host";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import { HubServerTransport } from "../server";
|
||||
import {
|
||||
@@ -797,6 +798,200 @@ describe("HubServerTransport boundaries", () => {
|
||||
expect(ctx.pendingCapabilityRequests.has("capreq-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let session metadata updates overwrite server-owned compaction owner", async () => {
|
||||
const updateSession = vi.fn().mockResolvedValue({ updated: true });
|
||||
const transport = createTransport({
|
||||
sessionHost: {
|
||||
updateSession,
|
||||
},
|
||||
});
|
||||
|
||||
await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-update",
|
||||
command: "session.update",
|
||||
clientId: "attacker-client",
|
||||
sessionId: "session-1",
|
||||
payload: {
|
||||
metadata: {
|
||||
hubCapabilityOwnerClientId: "attacker-client",
|
||||
title: "safe title",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateSession).toHaveBeenCalledWith("session-1", {
|
||||
metadata: { title: "safe title" },
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes compaction sidecar access from server session state, not mutable metadata", async () => {
|
||||
const readSessionCompactionState = vi.fn();
|
||||
const transport = createTransport({
|
||||
sessionHost: {
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
status: "completed",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
workspaceRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
metadata: { hubCapabilityOwnerClientId: "attacker-client" },
|
||||
}),
|
||||
readSessionCompactionState,
|
||||
},
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact",
|
||||
command: "session.compaction.get",
|
||||
clientId: "attacker-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_wrong_client" },
|
||||
});
|
||||
expect(readSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns compaction sidecar state to the server-owned session client", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
|
||||
const transport = createTransport({
|
||||
sessionHost: { readSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-get",
|
||||
command: "session.compaction.get",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: true,
|
||||
payload: { sessionId: "session-1", state },
|
||||
});
|
||||
expect(readSessionCompactionState).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
it("rejects invalid compaction sidecar updates before calling the session host", async () => {
|
||||
const updateSessionCompactionState = vi.fn();
|
||||
const transport = createTransport({
|
||||
sessionHost: { updateSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-update-invalid",
|
||||
command: "session.compaction.update",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state: { version: 1, messages: "bad" } },
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "invalid_compaction_state" },
|
||||
});
|
||||
expect(updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes session updates after successful compaction sidecar updates", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const updateSessionCompactionState = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true });
|
||||
const transport = createTransport({
|
||||
sessionHost: { updateSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
const events: HubEventEnvelope[] = [];
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
transport.subscribe("owner-client", (event) => events.push(event));
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-update",
|
||||
command: "session.compaction.update",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state },
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: true,
|
||||
payload: { updated: true },
|
||||
});
|
||||
expect(updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
state,
|
||||
);
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
event: "session.updated",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not publish session updates when compaction sidecar update is stale", async () => {
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [{ role: "user", content: "source" }],
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
conversationId: "session-1",
|
||||
});
|
||||
const updateSessionCompactionState = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: false });
|
||||
const transport = createTransport({
|
||||
sessionHost: { updateSessionCompactionState },
|
||||
});
|
||||
const ctx = getContext(transport);
|
||||
const events: HubEventEnvelope[] = [];
|
||||
ensureSessionState(ctx, "session-1", "owner-client", "creator");
|
||||
transport.subscribe("owner-client", (event) => events.push(event));
|
||||
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
requestId: "req-compact-stale",
|
||||
command: "session.compaction.update",
|
||||
clientId: "owner-client",
|
||||
sessionId: "session-1",
|
||||
payload: { state },
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
payload: { updated: false },
|
||||
});
|
||||
expect(events).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ event: "session.updated" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels pending capability requests when a run is aborted", async () => {
|
||||
const abort = vi.fn().mockResolvedValue(undefined);
|
||||
const transport = createTransport({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "@cline/shared";
|
||||
import { createSessionId, parseRuntimeConfigExtensions } from "@cline/shared";
|
||||
import type { RuntimeSessionConfig } from "../../../runtime/host/runtime-host";
|
||||
import { parseSessionCompactionState } from "../../../session/models/session-compaction";
|
||||
import {
|
||||
SessionVersioningError,
|
||||
SessionVersioningService,
|
||||
@@ -38,10 +39,49 @@ function setCapabilityOwner(
|
||||
}
|
||||
|
||||
function getCapabilityOwnerClientId(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
ctx: HubTransportContext,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
const owner = metadata?.[CAPABILITY_OWNER_METADATA_KEY];
|
||||
return typeof owner === "string" && owner.trim() ? owner.trim() : undefined;
|
||||
// Compaction sidecar access is intentionally tied to live hub session
|
||||
// ownership, not mutable persisted metadata. Sessions restored after the
|
||||
// hub forgets their live owner must be recreated or backfilled before using
|
||||
// these owner-scoped sidecar endpoints.
|
||||
return ctx.sessionState.get(sessionId)?.createdByClientId;
|
||||
}
|
||||
|
||||
function stripServerOwnedSessionMetadata(
|
||||
metadata: Record<string, JsonValue | undefined> | undefined,
|
||||
): Record<string, JsonValue | undefined> | undefined {
|
||||
if (!metadata || !(CAPABILITY_OWNER_METADATA_KEY in metadata)) {
|
||||
return metadata;
|
||||
}
|
||||
const sanitized = { ...metadata };
|
||||
delete sanitized[CAPABILITY_OWNER_METADATA_KEY];
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function authorizeSessionCompactionAccess(input: {
|
||||
sessionId: string;
|
||||
ctx: HubTransportContext;
|
||||
clientId: string;
|
||||
envelope: HubCommandEnvelope;
|
||||
}): HubReplyEnvelope | undefined {
|
||||
const ownerClientId = getCapabilityOwnerClientId(input.ctx, input.sessionId);
|
||||
if (!ownerClientId) {
|
||||
return errorReply(
|
||||
input.envelope,
|
||||
"session_wrong_client",
|
||||
`Session ${input.sessionId} has no authorized owner`,
|
||||
);
|
||||
}
|
||||
if (ownerClientId !== input.clientId) {
|
||||
return errorReply(
|
||||
input.envelope,
|
||||
"session_wrong_client",
|
||||
`Session ${input.sessionId} is owned by ${ownerClientId}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleSessionCreate(
|
||||
@@ -77,6 +117,9 @@ export async function handleSessionCreate(
|
||||
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
|
||||
? (payload.runtimeOptions as Record<string, unknown>)
|
||||
: {};
|
||||
const initialCompactionState = parseSessionCompactionState(
|
||||
payload.initialCompactionState,
|
||||
);
|
||||
if (typeof sessionConfig?.mode === "string") {
|
||||
metadata.mode = sessionConfig.mode;
|
||||
} else if (typeof runtimeOptions.mode === "string") {
|
||||
@@ -124,9 +167,7 @@ export async function handleSessionCreate(
|
||||
cwd: typeof payload.cwd === "string" ? payload.cwd : undefined,
|
||||
contributionCount: clientContributions.length,
|
||||
});
|
||||
if (clientContributions.length > 0) {
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
}
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
const requestedSessionId =
|
||||
typeof sessionConfig?.sessionId === "string"
|
||||
? sessionConfig.sessionId.trim()
|
||||
@@ -175,6 +216,7 @@ export async function handleSessionCreate(
|
||||
initialMessages: Array.isArray(payload.initialMessages)
|
||||
? (payload.initialMessages as never[])
|
||||
: undefined,
|
||||
initialCompactionState,
|
||||
localRuntime: {
|
||||
modelCatalogDefaults: {
|
||||
loadLatestOnInit: true,
|
||||
@@ -352,6 +394,9 @@ export async function handleSessionRestore(
|
||||
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
|
||||
? (payload.runtimeOptions as Record<string, unknown>)
|
||||
: {};
|
||||
const initialCompactionState = parseSessionCompactionState(
|
||||
payload.initialCompactionState,
|
||||
);
|
||||
const metadata =
|
||||
payload.metadata && typeof payload.metadata === "object"
|
||||
? JSON.parse(JSON.stringify(payload.metadata))
|
||||
@@ -380,9 +425,7 @@ export async function handleSessionRestore(
|
||||
const clientContributions = parseHubClientContributions(
|
||||
runtimeOptions.clientContributions,
|
||||
);
|
||||
if (clientContributions.length > 0) {
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
}
|
||||
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
|
||||
const requestedSessionId =
|
||||
typeof sessionConfig?.sessionId === "string"
|
||||
? sessionConfig.sessionId.trim()
|
||||
@@ -439,6 +482,7 @@ export async function handleSessionRestore(
|
||||
restoredCheckpointRunCount: checkpointRunCount,
|
||||
},
|
||||
initialMessages: context.initialMessages,
|
||||
initialCompactionState,
|
||||
localRuntime: {
|
||||
modelCatalogDefaults: {
|
||||
loadLatestOnInit: true,
|
||||
@@ -618,13 +662,7 @@ export async function handleSessionDetach(
|
||||
);
|
||||
}
|
||||
const clientId = envelope.clientId?.trim() || "hub-client";
|
||||
const [existingSession] = await Promise.all([
|
||||
readHubSessionRecord(ctx, sessionId),
|
||||
]);
|
||||
const ownerClientId =
|
||||
getCapabilityOwnerClientId(
|
||||
existingSession?.metadata as Record<string, unknown> | undefined,
|
||||
) ?? clientId;
|
||||
const ownerClientId = getCapabilityOwnerClientId(ctx, sessionId) ?? clientId;
|
||||
const state = ctx.sessionState.get(sessionId);
|
||||
if (state) {
|
||||
state.participants.delete(clientId);
|
||||
@@ -702,6 +740,40 @@ export async function handleSessionMessages(
|
||||
return okReply(envelope, { sessionId, messages });
|
||||
}
|
||||
|
||||
export async function handleSessionCompactionGet(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
if (!sessionId) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_session_id",
|
||||
"session.compaction.get requires a session id",
|
||||
);
|
||||
}
|
||||
const session = await readHubSessionRecord(ctx, sessionId);
|
||||
if (!session) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"session_not_found",
|
||||
`Unknown session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
const clientId = envelope.clientId?.trim() || "hub-client";
|
||||
const unauthorized = authorizeSessionCompactionAccess({
|
||||
sessionId,
|
||||
ctx,
|
||||
clientId,
|
||||
envelope,
|
||||
});
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
const state = await ctx.sessionHost.readSessionCompactionState(sessionId);
|
||||
return okReply(envelope, { sessionId, state });
|
||||
}
|
||||
|
||||
export async function handleSessionList(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
@@ -722,7 +794,9 @@ export async function handleSessionUpdate(
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
const metadata = asPlainRecord(envelope.payload?.metadata);
|
||||
const metadata = stripServerOwnedSessionMetadata(
|
||||
asPlainRecord(envelope.payload?.metadata),
|
||||
);
|
||||
const updated = await ctx.sessionHost.updateSession(sessionId, { metadata });
|
||||
const [session, snapshot] = await Promise.all([
|
||||
readHubSessionRecord(ctx, sessionId),
|
||||
@@ -749,6 +823,82 @@ export async function handleSessionUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionCompactionUpdate(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
): Promise<HubReplyEnvelope> {
|
||||
const sessionId = extractSessionId(envelope);
|
||||
if (!sessionId) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_session_id",
|
||||
"session.compaction.update requires a session id",
|
||||
);
|
||||
}
|
||||
const clientId = envelope.clientId?.trim() || "hub-client";
|
||||
const session = await readHubSessionRecord(ctx, sessionId);
|
||||
if (!session) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"session_not_found",
|
||||
`Unknown session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
const unauthorized = authorizeSessionCompactionAccess({
|
||||
sessionId,
|
||||
ctx,
|
||||
clientId,
|
||||
envelope,
|
||||
});
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
const payload =
|
||||
envelope.payload && typeof envelope.payload === "object"
|
||||
? envelope.payload
|
||||
: {};
|
||||
const state = parseSessionCompactionState(payload.state);
|
||||
if (!state) {
|
||||
return errorReply(
|
||||
envelope,
|
||||
"invalid_compaction_state",
|
||||
"session.compaction.update requires a valid compaction state",
|
||||
);
|
||||
}
|
||||
const updated = await ctx.sessionHost.updateSessionCompactionState(
|
||||
sessionId,
|
||||
state,
|
||||
);
|
||||
const [updatedSession, snapshot] = updated.updated
|
||||
? await Promise.all([
|
||||
readHubSessionRecord(ctx, sessionId),
|
||||
readCoreSessionSnapshot(ctx, sessionId),
|
||||
])
|
||||
: [session, undefined];
|
||||
if (updated.updated) {
|
||||
ctx.publish(
|
||||
ctx.buildEvent(
|
||||
"session.updated",
|
||||
{
|
||||
session: updatedSession ?? session,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
},
|
||||
sessionId,
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: envelope.version,
|
||||
requestId: envelope.requestId,
|
||||
ok: updated.updated,
|
||||
payload: {
|
||||
updated: updated.updated,
|
||||
session: updatedSession ?? session,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSessionDelete(
|
||||
ctx: HubTransportContext,
|
||||
envelope: HubCommandEnvelope,
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
import { projectSessionEvent } from "./handlers/session-event-projector";
|
||||
import {
|
||||
handleSessionAttach,
|
||||
handleSessionCompactionGet,
|
||||
handleSessionCompactionUpdate,
|
||||
handleSessionCreate,
|
||||
handleSessionDelete,
|
||||
handleSessionDetach,
|
||||
@@ -360,10 +362,14 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
return await handleSessionGet(this.ctx, envelope);
|
||||
case "session.messages":
|
||||
return await handleSessionMessages(this.ctx, envelope);
|
||||
case "session.compaction.get":
|
||||
return await handleSessionCompactionGet(this.ctx, envelope);
|
||||
case "session.list":
|
||||
return await handleSessionList(this.ctx, envelope);
|
||||
case "session.update":
|
||||
return await handleSessionUpdate(this.ctx, envelope);
|
||||
case "session.compaction.update":
|
||||
return await handleSessionCompactionUpdate(this.ctx, envelope);
|
||||
case "session.pending_prompts":
|
||||
return await handleSessionPendingPrompts(this.ctx, envelope);
|
||||
case "session.update_pending_prompt":
|
||||
|
||||
@@ -40,7 +40,6 @@ export type {
|
||||
ListProvidersActionRequest,
|
||||
Message,
|
||||
MessageWithMetadata,
|
||||
OAuthProviderId,
|
||||
ProviderActionRequest,
|
||||
ProviderCatalogResponse,
|
||||
ProviderListItem,
|
||||
@@ -117,7 +116,6 @@ export {
|
||||
} from "./auth/client";
|
||||
export {
|
||||
completeClineDeviceAuth,
|
||||
createClineOAuthProvider,
|
||||
getValidClineCredentials,
|
||||
loginClineOAuth,
|
||||
refreshClineToken,
|
||||
@@ -125,20 +123,30 @@ export {
|
||||
} from "./auth/cline";
|
||||
export {
|
||||
getValidOpenAICodexCredentials,
|
||||
isOpenAICodexTokenExpired,
|
||||
loginOpenAICodex,
|
||||
normalizeOpenAICodexCredentials,
|
||||
openaiCodexOAuthProvider,
|
||||
refreshOpenAICodexToken,
|
||||
} from "./auth/codex";
|
||||
export {
|
||||
createOcaOAuthProvider,
|
||||
createOcaRequestHeaders,
|
||||
generateOcaOpcRequestId,
|
||||
getValidOcaCredentials,
|
||||
loginOcaOAuth,
|
||||
refreshOcaToken,
|
||||
} from "./auth/oca";
|
||||
export {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderAuthHandler,
|
||||
getProviderAuthStorageId,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderAuthHandler,
|
||||
type ProviderAuthLoginInput,
|
||||
type ProviderAuthRefreshInput,
|
||||
type ProviderAuthSaveCredentialsInput,
|
||||
type ProviderOAuthCredentials,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "./auth/provider-auth-registry";
|
||||
export type {
|
||||
LocalOAuthServer,
|
||||
LocalOAuthServerOptions,
|
||||
@@ -455,6 +463,7 @@ export {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
refreshProviderModelsFromSource,
|
||||
@@ -632,7 +641,10 @@ export async function loadOpenTelemetryAdapter() {
|
||||
return import("./services/telemetry/index.js");
|
||||
}
|
||||
export { Agent, createAgentRuntime } from "@cline/agents";
|
||||
export { createContextCompactionPrepareTurn } from "./extensions/context/compaction";
|
||||
export {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
createContextCompactionPrepareTurn,
|
||||
} from "./extensions/context/compaction";
|
||||
export {
|
||||
ALL_DEFAULT_TOOL_NAMES,
|
||||
type AskQuestionExecutor,
|
||||
@@ -743,6 +755,12 @@ export {
|
||||
TelemetryService,
|
||||
type TelemetryServiceOptions,
|
||||
} from "./services/telemetry/TelemetryService";
|
||||
export {
|
||||
createSessionCompactionState,
|
||||
parseSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
} from "./session/models/session-compaction";
|
||||
// Compatibility barrel (legacy imports).
|
||||
export type { RuntimeEnvironment } from "./types";
|
||||
export type { SessionStatus } from "./types/common";
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
normalizeUserInput,
|
||||
} from "@cline/shared";
|
||||
import { setHomeDirIfUnset } from "@cline/shared/storage";
|
||||
import { createContextCompactionPrepareTurn } from "../../extensions/context/compaction";
|
||||
import { isOAuthProvider } from "../../auth/provider-auth-registry";
|
||||
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";
|
||||
@@ -46,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,
|
||||
@@ -169,6 +177,19 @@ function maxAccumulatedUsage(
|
||||
};
|
||||
}
|
||||
|
||||
function isIncomingCompactionStateStale(
|
||||
incoming: SessionCompactionState,
|
||||
current: SessionCompactionState | undefined,
|
||||
): boolean {
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
if (incoming.source_message_count !== current.source_message_count) {
|
||||
return incoming.source_message_count < current.source_message_count;
|
||||
}
|
||||
return incoming.updated_at < current.updated_at;
|
||||
}
|
||||
|
||||
export interface LocalRuntimeHostOptions {
|
||||
distinctId?: string;
|
||||
sessionService: SessionBackend;
|
||||
@@ -317,6 +338,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
messages_path: messagesPath,
|
||||
});
|
||||
let resumedArtifacts: RootSessionArtifacts | undefined;
|
||||
let resumedCompactionState: SessionCompactionState | undefined;
|
||||
const isReadOnlyResumeStart =
|
||||
requestedSessionId.length > 0 &&
|
||||
initialMessages.length > 0 &&
|
||||
@@ -331,8 +353,14 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
resumedArtifacts = {
|
||||
manifestPath,
|
||||
messagesPath: existingManifest.messages_path || messagesPath,
|
||||
compactionPath: existingManifest.compaction_path,
|
||||
manifest: existingManifest,
|
||||
};
|
||||
resumedCompactionState =
|
||||
await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const initialAggregateUsage = await this.seedAggregateUsageFromArtifacts({
|
||||
@@ -419,6 +447,80 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
|
||||
const tools = [...runtime.tools, ...(configWithProvider.extraTools ?? [])];
|
||||
const extensions = runtime.extensions ?? bootstrap.extensions;
|
||||
const explicitInitialCompactionState = startInput.initialCompactionState;
|
||||
let activeSessionRef: ActiveSession | undefined;
|
||||
const compact = createContextCompactionPrepareTurn(configWithProvider);
|
||||
const initialCompactionState =
|
||||
explicitInitialCompactionState ??
|
||||
(compact ? resumedCompactionState : undefined);
|
||||
const prepareTurn = createCompactionStateAwarePrepareTurn({
|
||||
compact,
|
||||
getState: () => activeSessionRef?.compactionState,
|
||||
saveState: async (state) => {
|
||||
const activeSession = activeSessionRef;
|
||||
if (!activeSession) return;
|
||||
const stateForSession = {
|
||||
...state,
|
||||
conversation_id: activeSession.sessionId,
|
||||
};
|
||||
try {
|
||||
const result = await this.persistActiveSessionCompactionState(
|
||||
activeSession,
|
||||
stateForSession,
|
||||
);
|
||||
if (!result.updated) {
|
||||
configWithProvider.logger?.debug?.(
|
||||
"Skipped stale session compaction state",
|
||||
{
|
||||
sessionId: activeSession.sessionId,
|
||||
sourceMessageCount: stateForSession.source_message_count,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
configWithProvider.logger?.error?.(
|
||||
"Failed to persist session compaction state",
|
||||
{ sessionId: activeSession.sessionId, error },
|
||||
);
|
||||
captureSdkError(configWithProvider.telemetry, {
|
||||
component: "core",
|
||||
operation: "session.persist_compaction_state",
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
error,
|
||||
context: {
|
||||
sessionId: activeSession.sessionId,
|
||||
providerId: configWithProvider.providerId,
|
||||
modelId: configWithProvider.modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
clearState: async () => {
|
||||
const activeSession = activeSessionRef;
|
||||
if (!activeSession?.compactionState) return;
|
||||
try {
|
||||
await this.clearActiveSessionCompactionState(activeSession);
|
||||
} catch (error) {
|
||||
configWithProvider.logger?.error?.(
|
||||
"Failed to delete stale session compaction state",
|
||||
{ sessionId: activeSession.sessionId, error },
|
||||
);
|
||||
captureSdkError(configWithProvider.telemetry, {
|
||||
component: "core",
|
||||
operation: "session.delete_compaction_state",
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
error,
|
||||
context: {
|
||||
sessionId: activeSession.sessionId,
|
||||
providerId: configWithProvider.providerId,
|
||||
modelId: configWithProvider.modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const agentConfig = {
|
||||
sessionId,
|
||||
@@ -435,7 +537,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
systemPrompt: configWithProvider.systemPrompt,
|
||||
maxIterations: configWithProvider.maxIterations,
|
||||
execution: configWithProvider.execution,
|
||||
prepareTurn: createContextCompactionPrepareTurn(configWithProvider),
|
||||
prepareTurn,
|
||||
tools,
|
||||
hooks: bootstrap.hooks,
|
||||
extensions,
|
||||
@@ -579,6 +681,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
aborting: false,
|
||||
interactive: input.interactive === true,
|
||||
persistedMessages: initialMessages,
|
||||
compactionState: initialCompactionState,
|
||||
activeTeamRunIds: new Set<string>(),
|
||||
pendingTeamRunUpdates: [],
|
||||
teamRunWaiters: [],
|
||||
@@ -588,6 +691,25 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
submitAndExitObserved: false,
|
||||
lastInteractiveTurnFinishReason: undefined,
|
||||
};
|
||||
activeSessionRef = active;
|
||||
if (
|
||||
active.compactionState &&
|
||||
!this.isCompactionStateForSession(
|
||||
active.sessionId,
|
||||
active.compactionState,
|
||||
active,
|
||||
)
|
||||
) {
|
||||
active.config.logger?.log?.(
|
||||
"Ignoring session compaction state for a different conversation",
|
||||
{
|
||||
severity: "warn",
|
||||
sessionId: active.sessionId,
|
||||
conversationId: active.compactionState.conversation_id,
|
||||
},
|
||||
);
|
||||
active.compactionState = undefined;
|
||||
}
|
||||
this.sessions.set(sessionId, active);
|
||||
this.emitStatus(sessionId, "running");
|
||||
if (initialMessages.length > 0 && !resumedArtifacts) {
|
||||
@@ -598,6 +720,15 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
initialMessages,
|
||||
active.config.systemPrompt,
|
||||
);
|
||||
if (active.compactionState) {
|
||||
const result = await this.persistActiveSessionCompactionState(
|
||||
active,
|
||||
active.compactionState,
|
||||
);
|
||||
if (!result.updated) {
|
||||
active.compactionState = undefined;
|
||||
}
|
||||
}
|
||||
if (!startInput.prompt?.trim()) {
|
||||
await this.updateStatus(active, "completed", 0);
|
||||
}
|
||||
@@ -882,6 +1013,168 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
return { updated: result?.updated === true };
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return { updated: false };
|
||||
const activeSession = this.sessions.get(target);
|
||||
const sessionRecord = activeSession
|
||||
? undefined
|
||||
: await this.getSession(target);
|
||||
const existing = activeSession ?? sessionRecord;
|
||||
if (!existing) return { updated: false };
|
||||
if (
|
||||
!(await this.canPersistCompactionState(
|
||||
target,
|
||||
state,
|
||||
activeSession,
|
||||
sessionRecord,
|
||||
))
|
||||
) {
|
||||
return { updated: false };
|
||||
}
|
||||
if (activeSession) {
|
||||
return await this.persistActiveSessionCompactionState(
|
||||
activeSession,
|
||||
state,
|
||||
);
|
||||
}
|
||||
const current = await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
target,
|
||||
);
|
||||
if (isIncomingCompactionStateStale(state, current)) {
|
||||
return { updated: false };
|
||||
}
|
||||
await this.invoke<void>("persistSessionCompactionState", target, state);
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return undefined;
|
||||
const activeSession = this.sessions.get(target);
|
||||
if (activeSession) {
|
||||
for (;;) {
|
||||
const pendingWrite = activeSession.compactionStateWriteQueue;
|
||||
if (!pendingWrite) {
|
||||
return activeSession.compactionState;
|
||||
}
|
||||
await pendingWrite.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
private isCompactionStateForSession(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
activeSession?: ActiveSession,
|
||||
sessionRecord?: SessionRecord,
|
||||
): boolean {
|
||||
const conversationId = state.conversation_id?.trim();
|
||||
if (!conversationId) {
|
||||
return true;
|
||||
}
|
||||
if (conversationId === sessionId) {
|
||||
return true;
|
||||
}
|
||||
const expectedConversationId =
|
||||
activeSession?.agent.getConversationId()?.trim() ||
|
||||
sessionRecord?.conversationId?.trim();
|
||||
return expectedConversationId
|
||||
? conversationId === expectedConversationId
|
||||
: false;
|
||||
}
|
||||
|
||||
private async canPersistCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
activeSession?: ActiveSession,
|
||||
sessionRecord?: SessionRecord,
|
||||
): Promise<boolean> {
|
||||
if (!state.conversation_id?.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isCompactionStateForSession(
|
||||
sessionId,
|
||||
state,
|
||||
activeSession,
|
||||
sessionRecord,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sourceMessages =
|
||||
activeSession?.agent.getMessages() ??
|
||||
(await this.readSessionMessages(sessionId));
|
||||
return projectSessionCompactionState(state, sourceMessages) !== undefined;
|
||||
}
|
||||
|
||||
private async persistActiveSessionCompactionState(
|
||||
session: ActiveSession,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }> {
|
||||
if (!(await this.canPersistCompactionState(session.sessionId, state, session))) {
|
||||
return { updated: false };
|
||||
}
|
||||
return await this.enqueueCompactionStateWrite(session, async () => {
|
||||
if (isIncomingCompactionStateStale(state, session.compactionState)) {
|
||||
return { updated: false };
|
||||
}
|
||||
await this.invoke<void>(
|
||||
"persistSessionCompactionState",
|
||||
session.sessionId,
|
||||
state,
|
||||
);
|
||||
session.compactionState = state;
|
||||
return { updated: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async clearActiveSessionCompactionState(
|
||||
session: ActiveSession,
|
||||
): Promise<void> {
|
||||
await this.enqueueCompactionStateWrite(session, async () => {
|
||||
if (!session.compactionState) {
|
||||
return;
|
||||
}
|
||||
await this.invoke<void>(
|
||||
"deleteSessionCompactionState",
|
||||
session.sessionId,
|
||||
);
|
||||
session.compactionState = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async enqueueCompactionStateWrite<T>(
|
||||
session: ActiveSession,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = session.compactionStateWriteQueue ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(action);
|
||||
const tracked = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
session.compactionStateWriteQueue = tracked;
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (session.compactionStateWriteQueue === tracked) {
|
||||
session.compactionStateWriteQueue = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async readSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<LlmsProviders.Message[]> {
|
||||
@@ -1533,7 +1826,10 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!isLikelyAuthError(error, session.config.providerId)) {
|
||||
if (
|
||||
!isOAuthProvider(session.config.providerId) ||
|
||||
!isLikelyAuthError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await this.syncOAuthCredentials(session, { forceRefresh: true });
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import type { HookEventPayload } from "../../hooks";
|
||||
import type { CheckpointEntry } from "../../hooks/checkpoint-hooks";
|
||||
import type { ProviderSettings } from "../../services/llms/provider-settings";
|
||||
import type { SessionCompactionState } from "../../session/models/session-compaction";
|
||||
import type { SessionManifest } from "../../session/models/session-manifest";
|
||||
import type { SessionSource } from "../../types/common";
|
||||
import type { CoreSessionConfig } from "../../types/config";
|
||||
@@ -104,6 +105,7 @@ export interface StartSessionInput {
|
||||
interactive?: boolean;
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: LlmsProviders.Message[];
|
||||
initialCompactionState?: SessionCompactionState;
|
||||
userImages?: string[];
|
||||
userFiles?: string[];
|
||||
/**
|
||||
@@ -308,6 +310,13 @@ export interface RuntimeHost {
|
||||
title?: string | null;
|
||||
},
|
||||
): Promise<{ updated: boolean }>;
|
||||
updateSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<{ updated: boolean }>;
|
||||
readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined>;
|
||||
readSessionMessages(sessionId: string): Promise<LlmsProviders.Message[]>;
|
||||
dispatchHookEvent(payload: HookEventPayload): Promise<void>;
|
||||
subscribe(
|
||||
|
||||
@@ -128,6 +128,55 @@ describe("RuntimeEventAdapter — suppressed events", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeEventAdapter — status notices", () => {
|
||||
let adapter: RuntimeEventAdapter;
|
||||
beforeEach(() => {
|
||||
adapter = new RuntimeEventAdapter();
|
||||
});
|
||||
|
||||
it("preserves bounded compaction reasons", () => {
|
||||
for (const reason of [
|
||||
"auto_compaction",
|
||||
"manual_compaction",
|
||||
"compaction_budget_emergency",
|
||||
] as const) {
|
||||
const out = adapter.translate({
|
||||
type: "status-notice",
|
||||
snapshot: makeSnapshot(),
|
||||
message: "compaction status",
|
||||
metadata: { reason },
|
||||
});
|
||||
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: "compaction status",
|
||||
reason,
|
||||
metadata: { reason },
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not promote arbitrary status reasons", () => {
|
||||
const out = adapter.translate({
|
||||
type: "status-notice",
|
||||
snapshot: makeSnapshot(),
|
||||
message: "custom",
|
||||
metadata: { reason: "surprise" },
|
||||
});
|
||||
|
||||
expect(out[0]).toMatchObject({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
message: "custom",
|
||||
reason: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Iteration lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -66,6 +66,21 @@ import type {
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
type StatusNoticeReason = Extract<AgentEvent, { type: "notice" }>["reason"];
|
||||
|
||||
function resolveStatusNoticeReason(
|
||||
reason: unknown,
|
||||
): StatusNoticeReason | undefined {
|
||||
switch (reason) {
|
||||
case "auto_compaction":
|
||||
case "manual_compaction":
|
||||
case "compaction_budget_emergency":
|
||||
return reason;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTextPart(message: AgentMessage): string | undefined {
|
||||
const parts = message.content.filter(
|
||||
(part): part is AgentTextPart => part.type === "text",
|
||||
@@ -225,10 +240,7 @@ export class RuntimeEventAdapter {
|
||||
noticeType: "status",
|
||||
displayRole: "status",
|
||||
message: event.message,
|
||||
reason:
|
||||
event.metadata?.reason === "auto_compaction"
|
||||
? "auto_compaction"
|
||||
: undefined,
|
||||
reason: resolveStatusNoticeReason(event.metadata?.reason),
|
||||
metadata: event.metadata,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,102 +1,13 @@
|
||||
import type { ITelemetryService } from "@cline/shared";
|
||||
import {
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
isOAuthProviderId,
|
||||
type OAuthProviderId,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
type ClineOAuthCredentials,
|
||||
getValidClineCredentials,
|
||||
} from "../../auth/cline";
|
||||
import { getValidOpenAICodexCredentials } from "../../auth/codex";
|
||||
import { getValidOcaCredentials } from "../../auth/oca";
|
||||
import { decodeJwtPayload } from "../../auth/utils";
|
||||
getProviderAuthHandler,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "../../auth/provider-auth-registry";
|
||||
import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager";
|
||||
import type { ProviderSettings } from "../../types/provider-settings";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
|
||||
type ManagedOAuthProviderId = OAuthProviderId;
|
||||
|
||||
function toStoredAccessToken(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
accessToken: string,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return `${WORKOS_TOKEN_PREFIX}${accessToken}`;
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
function fromStoredAccessToken(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
accessToken: string,
|
||||
): string {
|
||||
if (
|
||||
providerId === "cline" &&
|
||||
accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
) {
|
||||
return accessToken.slice(WORKOS_TOKEN_PREFIX.length);
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
function readExpiryFromToken(accessToken: string): number | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const exp = payload?.exp;
|
||||
if (typeof exp === "number" && exp > 0) {
|
||||
return exp * 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveCredentialExpiry(
|
||||
settings: ProviderSettings,
|
||||
normalizedAccessToken: string,
|
||||
): number {
|
||||
const explicitExpiry = (
|
||||
settings.auth as
|
||||
| (ProviderSettings["auth"] & { expiresAt?: number })
|
||||
| undefined
|
||||
)?.expiresAt;
|
||||
if (
|
||||
typeof explicitExpiry === "number" &&
|
||||
Number.isFinite(explicitExpiry) &&
|
||||
explicitExpiry > 0
|
||||
) {
|
||||
return explicitExpiry;
|
||||
}
|
||||
|
||||
const jwtExpiry = readExpiryFromToken(normalizedAccessToken);
|
||||
if (jwtExpiry) {
|
||||
return jwtExpiry;
|
||||
}
|
||||
|
||||
// Unknown expiry should trigger refresh on next resolution.
|
||||
return Date.now() - 1;
|
||||
}
|
||||
|
||||
function toCredentials(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
settings: ProviderSettings,
|
||||
): ClineOAuthCredentials | null {
|
||||
const rawAccess = settings.auth?.accessToken?.trim();
|
||||
const refreshToken = settings.auth?.refreshToken?.trim();
|
||||
if (!rawAccess || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const access = fromStoredAccessToken(providerId, rawAccess);
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh: refreshToken,
|
||||
expires: deriveCredentialExpiry(settings, access),
|
||||
accountId: settings.auth?.accountId,
|
||||
};
|
||||
}
|
||||
type ManagedOAuthProviderId = string;
|
||||
|
||||
function authSettingsEqual(
|
||||
a: ProviderSettings["auth"] | undefined,
|
||||
@@ -156,10 +67,11 @@ export class RuntimeOAuthTokenManager {
|
||||
providerId: string;
|
||||
forceRefresh?: boolean;
|
||||
}): Promise<RuntimeOAuthResolution | null> {
|
||||
if (!isOAuthProviderId(input.providerId)) {
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
if (!handler) {
|
||||
return null;
|
||||
}
|
||||
return this.resolveWithSingleFlight(input.providerId, input.forceRefresh);
|
||||
return this.resolveWithSingleFlight(handler.providerId, input.forceRefresh);
|
||||
}
|
||||
|
||||
private async resolveWithSingleFlight(
|
||||
@@ -185,42 +97,43 @@ export class RuntimeOAuthTokenManager {
|
||||
providerId: ManagedOAuthProviderId,
|
||||
forceRefresh: boolean,
|
||||
): Promise<RuntimeOAuthResolution | null> {
|
||||
const settings =
|
||||
this.providerSettingsManager.getProviderSettings(providerId);
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
return null;
|
||||
}
|
||||
const settings = this.providerSettingsManager.getProviderSettings(
|
||||
handler.storageProviderId,
|
||||
);
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentCredentials = toCredentials(providerId, settings);
|
||||
const currentCredentials = getProviderOAuthCredentialsFromSettings(
|
||||
providerId,
|
||||
settings,
|
||||
);
|
||||
if (!currentCredentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextCredentials = await this.resolveCredentials(
|
||||
providerId,
|
||||
const nextCredentials = await handler.refresh({
|
||||
settings,
|
||||
currentCredentials,
|
||||
credentials: currentCredentials,
|
||||
forceRefresh,
|
||||
);
|
||||
telemetry: this.telemetry,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new OAuthReauthRequiredError(providerId);
|
||||
}
|
||||
|
||||
const persistedAccessToken = toStoredAccessToken(
|
||||
const nextSettings: ProviderSettings = saveProviderOAuthCredentials({
|
||||
manager: this.providerSettingsManager,
|
||||
providerId,
|
||||
nextCredentials.access,
|
||||
);
|
||||
const nextAuth = {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: persistedAccessToken,
|
||||
refreshToken: nextCredentials.refresh,
|
||||
accountId: nextCredentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
nextAuth.expiresAt = nextCredentials.expires;
|
||||
const nextSettings: ProviderSettings = {
|
||||
...settings,
|
||||
auth: nextAuth,
|
||||
};
|
||||
settings,
|
||||
credentials: nextCredentials,
|
||||
setLastUsed: false,
|
||||
save: false,
|
||||
});
|
||||
const wasRefreshed = !authSettingsEqual(settings.auth, nextSettings.auth);
|
||||
if (wasRefreshed) {
|
||||
this.providerSettingsManager.saveProviderSettings(nextSettings, {
|
||||
@@ -231,39 +144,9 @@ export class RuntimeOAuthTokenManager {
|
||||
|
||||
return {
|
||||
providerId,
|
||||
apiKey: persistedAccessToken,
|
||||
apiKey: handler.getApiKey(nextSettings) ?? nextCredentials.access,
|
||||
accountId: nextCredentials.accountId,
|
||||
refreshed: wasRefreshed,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveCredentials(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
settings: ProviderSettings,
|
||||
currentCredentials: ClineOAuthCredentials,
|
||||
forceRefresh: boolean,
|
||||
): Promise<ClineOAuthCredentials | null> {
|
||||
if (providerId === "cline") {
|
||||
return getValidClineCredentials(
|
||||
currentCredentials,
|
||||
{
|
||||
apiBaseUrl:
|
||||
settings.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
telemetry: this.telemetry,
|
||||
},
|
||||
{ forceRefresh },
|
||||
);
|
||||
}
|
||||
if (providerId === "oca") {
|
||||
return getValidOcaCredentials(
|
||||
currentCredentials,
|
||||
{ forceRefresh, telemetry: this.telemetry },
|
||||
{ mode: settings.oca?.mode, telemetry: this.telemetry },
|
||||
);
|
||||
}
|
||||
return getValidOpenAICodexCredentials(currentCredentials, {
|
||||
forceRefresh,
|
||||
telemetry: this.telemetry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,8 +1248,7 @@ describe("normalizeOAuthProvider", () => {
|
||||
expect(normalizeOAuthProvider("OCA")).toBe("oca");
|
||||
});
|
||||
|
||||
it("normalizes 'codex' and 'openai-codex' to 'openai-codex'", () => {
|
||||
expect(normalizeOAuthProvider("codex")).toBe("openai-codex");
|
||||
it("normalizes 'openai-codex' to 'openai-codex'", () => {
|
||||
expect(normalizeOAuthProvider("openai-codex")).toBe("openai-codex");
|
||||
expect(normalizeOAuthProvider("OPENAI-CODEX")).toBe("openai-codex");
|
||||
});
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import {
|
||||
type AddProviderActionRequest,
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
type OAuthProviderId,
|
||||
type ProviderCapability,
|
||||
type ProviderConfigField,
|
||||
type ProviderConfigFieldPrimitive,
|
||||
type ProviderListItem,
|
||||
type ProviderModel,
|
||||
type SaveProviderSettingsActionRequest,
|
||||
import type {
|
||||
AddProviderActionRequest,
|
||||
ITelemetryService,
|
||||
ProviderCapability,
|
||||
ProviderConfigField,
|
||||
ProviderConfigFieldPrimitive,
|
||||
ProviderListItem,
|
||||
ProviderModel,
|
||||
SaveProviderSettingsActionRequest,
|
||||
} from "@cline/shared";
|
||||
import { createOAuthClientCallbacks } from "../../auth/client";
|
||||
import { loginClineOAuth } from "../../auth/cline";
|
||||
import { loginOpenAICodex } from "../../auth/codex";
|
||||
import { loginOcaOAuth } from "../../auth/oca";
|
||||
import {
|
||||
getProviderAuthHandler,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderOAuthCredentials,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "../../auth/provider-auth-registry";
|
||||
import { resolveProviderConfig } from "../../services/llms/provider-defaults";
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -849,39 +850,23 @@ export async function refreshProviderModelsFromSource(
|
||||
return { providerId: id, refreshed: true, modelsCount: result.modelsCount };
|
||||
}
|
||||
|
||||
export function normalizeOAuthProvider(provider: string): OAuthProviderId {
|
||||
export function normalizeOAuthProvider(provider: string): string {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (normalized === "codex" || normalized === "openai-codex")
|
||||
return "openai-codex";
|
||||
if (normalized === "cline" || normalized === "oca") return normalized;
|
||||
throw new Error(
|
||||
`provider "${provider}" does not support OAuth login (supported: cline, oca, openai-codex)`,
|
||||
);
|
||||
}
|
||||
|
||||
function toProviderApiKey(
|
||||
providerId: OAuthProviderId,
|
||||
credentials: { access: string },
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
const handler = getProviderAuthHandler(normalized);
|
||||
if (handler) return handler.providerId;
|
||||
throw new Error(`provider "${provider}" does not support OAuth login`);
|
||||
}
|
||||
|
||||
export async function loginLocalProvider(
|
||||
providerId: OAuthProviderId,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
openUrl: (url: string) => void,
|
||||
telemetry?: ITelemetryService,
|
||||
): Promise<{
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
}> {
|
||||
): Promise<ProviderOAuthCredentials> {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
throw new Error(`provider "${providerId}" does not support OAuth login`);
|
||||
}
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: async (prompt) => prompt.defaultValue ?? "",
|
||||
openUrl,
|
||||
@@ -889,55 +874,42 @@ export async function loginLocalProvider(
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
if (providerId === "oca")
|
||||
return loginOcaOAuth({ mode: existing?.oca?.mode, callbacks, telemetry });
|
||||
return loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
telemetry,
|
||||
});
|
||||
return handler.login({ settings: existing, callbacks, telemetry });
|
||||
}
|
||||
|
||||
export function saveLocalProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: OAuthProviderId,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
},
|
||||
credentials: ProviderOAuthCredentials,
|
||||
options?: { setLastUsed?: boolean },
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
return saveProviderOAuthCredentials({
|
||||
manager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
setLastUsed: options?.setLastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
manager.saveProviderSettings(merged, { tokenSource: "oauth" });
|
||||
return merged;
|
||||
export async function loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
openUrl: (url: string) => void,
|
||||
telemetry?: ITelemetryService,
|
||||
): Promise<ProviderSettings> {
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: async (prompt) => prompt.defaultValue ?? "",
|
||||
openUrl,
|
||||
onOpenUrlError: ({ error }) => {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
},
|
||||
});
|
||||
return loginAndSaveProviderOAuthCredentials(manager, providerId, {
|
||||
callbacks,
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveLocalClineAuthToken(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import { isOAuthProvider } from "../../auth/provider-auth-registry";
|
||||
|
||||
export type ProviderConfigFieldKey =
|
||||
| "apiKey"
|
||||
@@ -186,7 +186,7 @@ export function getProviderConfigFields(
|
||||
providerId: string,
|
||||
): ProviderConfigFields {
|
||||
const id = LlmsModels.normalizeProviderId(providerId);
|
||||
if (isOAuthProviderId(id)) {
|
||||
if (isOAuthProvider(id)) {
|
||||
return { providerId: id, authMethod: "oauth", fields: {} };
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,13 @@ export class SessionArtifacts {
|
||||
);
|
||||
}
|
||||
|
||||
public sessionCompactionPath(sessionId: string): string {
|
||||
return join(
|
||||
this.sessionArtifactsDir(sessionId),
|
||||
`${sessionId}.compaction.json`,
|
||||
);
|
||||
}
|
||||
|
||||
public sessionManifestPath(sessionId: string, ensureDir = false): string {
|
||||
const base = ensureDir
|
||||
? this.ensureSessionArtifactsDir(sessionId)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ITelemetryService } from "@cline/shared";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
CORE_TELEMETRY_EVENTS,
|
||||
captureCompactionBudgetEmergency,
|
||||
captureCompactionExecuted,
|
||||
captureCompactionSkipped,
|
||||
captureExtensionActivated,
|
||||
@@ -441,6 +442,38 @@ describe("captureRunCommandsTimeout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureCompactionBudgetEmergency", () => {
|
||||
test("emits task.compaction_budget_emergency with action metadata", () => {
|
||||
const stub = createTelemetryStub();
|
||||
captureCompactionBudgetEmergency(stub.telemetry, {
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 2,
|
||||
warningCount: 1,
|
||||
liveTailHandling: "included_degraded",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4",
|
||||
});
|
||||
|
||||
const { event, properties } = captureCallAt(stub, 0);
|
||||
expect(event).toBe("task.compaction_budget_emergency");
|
||||
expect(properties).toMatchObject({
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 2,
|
||||
warningCount: 1,
|
||||
liveTailHandling: "included_degraded",
|
||||
});
|
||||
expect(typeof (properties as Record<string, unknown>).timestamp).toBe(
|
||||
"string",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Telemetry-policy regression coverage.
|
||||
*
|
||||
@@ -603,6 +636,24 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
|
||||
expect(emitRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("captureCompactionBudgetEmergency never invokes captureRequired", () => {
|
||||
const { adapter, emitRequired } = createDisabledAdapter();
|
||||
const service = new TelemetryService({
|
||||
distinctId: "test-distinct-id",
|
||||
adapters: [adapter],
|
||||
});
|
||||
captureCompactionBudgetEmergency(service, {
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 1,
|
||||
warningCount: 0,
|
||||
liveTailHandling: "included_degraded",
|
||||
});
|
||||
expect(emitRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a correctly-policed adapter drops these events when disabled", () => {
|
||||
// This test layers on top of the previous four to assert the *full*
|
||||
// end-to-end policy: when the adapter is disabled, a real adapter
|
||||
@@ -681,6 +732,15 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
|
||||
command_count: 2,
|
||||
duration_ms: 1502,
|
||||
});
|
||||
captureCompactionBudgetEmergency(service, {
|
||||
ulid: "ulid-1",
|
||||
strategy: "basic",
|
||||
mode: "auto",
|
||||
policyIntent: "basic_compaction_projection",
|
||||
actionCount: 1,
|
||||
warningCount: 0,
|
||||
liveTailHandling: "included_degraded",
|
||||
});
|
||||
expect(observed).toEqual([]);
|
||||
expect(dropped).toEqual([
|
||||
"user.extension_activated",
|
||||
@@ -691,6 +751,7 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
|
||||
"task.compaction_executed",
|
||||
"task.compaction_skipped",
|
||||
"sdk.tool_timeout",
|
||||
"task.compaction_budget_emergency",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
SDK_ERROR_TELEMETRY_EVENT,
|
||||
type TelemetryProperties,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
CoreCompactionBudgetPolicyIntent,
|
||||
CoreCompactionLiveTailHandling,
|
||||
} from "../../types/config";
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -61,6 +65,7 @@ export const CORE_TELEMETRY_EVENTS = {
|
||||
SUBAGENT_COMPLETED: "task.subagent_completed",
|
||||
COMPACTION_EXECUTED: "task.compaction_executed",
|
||||
COMPACTION_SKIPPED: "task.compaction_skipped",
|
||||
COMPACTION_BUDGET_EMERGENCY: "task.compaction_budget_emergency",
|
||||
},
|
||||
HOOKS: {
|
||||
DISCOVERY_COMPLETED: "hooks.discovery_completed",
|
||||
@@ -678,3 +683,26 @@ export function captureCompactionSkipped(
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export interface CaptureCompactionBudgetEmergencyProperties {
|
||||
ulid: string;
|
||||
strategy: TelemetryCompactionStrategy;
|
||||
mode: TelemetryCompactionMode;
|
||||
policyIntent: CoreCompactionBudgetPolicyIntent;
|
||||
actionCount: number;
|
||||
warningCount: number;
|
||||
liveTailHandling: CoreCompactionLiveTailHandling;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export function captureCompactionBudgetEmergency(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
properties: CaptureCompactionBudgetEmergencyProperties &
|
||||
Partial<TelemetryAgentIdentityProperties>,
|
||||
): void {
|
||||
emit(telemetry, CORE_TELEMETRY_EVENTS.TASK.COMPACTION_BUDGET_EMERGENCY, {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
parseSessionCompactionState,
|
||||
projectSessionCompactionState,
|
||||
} from "./session-compaction";
|
||||
|
||||
describe("session compaction state", () => {
|
||||
it("rejects projection when the canonical prefix was edited before the boundary", () => {
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "original detail" },
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const editedPrefix = [
|
||||
{ ...sourceMessages[0], content: "redacted detail" },
|
||||
sourceMessages[1],
|
||||
{ id: "u2", role: "user" as const, content: "tail" },
|
||||
];
|
||||
|
||||
expect(projectSessionCompactionState(state, editedPrefix)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("projects compacted state when the canonical prefix matches exactly", () => {
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "original detail" },
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const tail = { id: "u2", role: "user" as const, content: "tail" };
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(
|
||||
projectSessionCompactionState(state, [...sourceMessages, tail]),
|
||||
).toEqual([...compactedMessages, tail]);
|
||||
});
|
||||
|
||||
it("projects when resumed user input was display-normalized from persisted history", () => {
|
||||
const sourceMessages = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: '<user_input mode="act">hello</user_input>',
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: '<user_input mode="act">inspect</user_input>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const resumedMessages = [
|
||||
{ ...sourceMessages[0], content: "hello" },
|
||||
{
|
||||
...sourceMessages[1],
|
||||
content: [{ type: "text" as const, text: "inspect" }],
|
||||
},
|
||||
sourceMessages[2],
|
||||
{ id: "u3", role: "user" as const, content: "tail" },
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(projectSessionCompactionState(state, resumedMessages)).toEqual([
|
||||
...compactedMessages,
|
||||
resumedMessages[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects anchor-free sidecars even when the source count is zero", () => {
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
source_message_count: 0,
|
||||
messages: [
|
||||
{ id: "summary", role: "user" as const, content: "unanchored" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(state).toBeDefined();
|
||||
if (!state) {
|
||||
throw new Error("expected parsed compaction state");
|
||||
}
|
||||
expect(
|
||||
projectSessionCompactionState(state, [
|
||||
{ id: "u1", role: "user", content: "canonical" },
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects malformed sidecar timestamps", () => {
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
updated_at: "not-a-date",
|
||||
source_message_count: 1,
|
||||
source_prefix_hash: "sha256:test",
|
||||
messages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
} from "@cline/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
function isMessageWithMetadata(value: unknown): value is MessageWithMetadata {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<MessageWithMetadata>;
|
||||
if (candidate.role !== "user" && candidate.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof candidate.content === "string" || Array.isArray(candidate.content)
|
||||
);
|
||||
}
|
||||
|
||||
const MessageWithMetadataSchema = z.custom<MessageWithMetadata>(
|
||||
isMessageWithMetadata,
|
||||
);
|
||||
|
||||
export const SessionCompactionStateSchema = z.object({
|
||||
version: z.literal(1),
|
||||
updated_at: z.string().datetime(),
|
||||
conversation_id: z.string().min(1).optional(),
|
||||
source_message_count: z.number().int().nonnegative(),
|
||||
source_prefix_hash: z.string().min(1).optional(),
|
||||
source_last_message_key: z.string().min(1).optional(),
|
||||
messages: z.array(MessageWithMetadataSchema),
|
||||
system_prompt: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SessionCompactionState = z.infer<
|
||||
typeof SessionCompactionStateSchema
|
||||
>;
|
||||
|
||||
function cloneMessages(
|
||||
messages: readonly MessageWithMetadata[],
|
||||
): MessageWithMetadata[] {
|
||||
return JSON.parse(canonicalJson(messages)) as MessageWithMetadata[];
|
||||
}
|
||||
|
||||
function toCanonicalJsonValue(value: unknown, seen: WeakSet<object>): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
throw new TypeError("Cannot serialize bigint in session compaction state");
|
||||
}
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === "function" ||
|
||||
typeof value === "symbol"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const withToJson = value as { toJSON?: () => unknown };
|
||||
if (typeof withToJson.toJSON === "function") {
|
||||
const jsonValue = withToJson.toJSON();
|
||||
if (jsonValue !== value) {
|
||||
return toCanonicalJsonValue(jsonValue, seen);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new TypeError("Cannot serialize circular session compaction state");
|
||||
}
|
||||
seen.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
const normalized = toCanonicalJsonValue(item, seen);
|
||||
return normalized === undefined ? null : normalized;
|
||||
});
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(record).sort()) {
|
||||
const item = toCanonicalJsonValue(record[key], seen);
|
||||
if (item !== undefined) {
|
||||
normalized[key] = item;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
} finally {
|
||||
seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
const json = JSON.stringify(
|
||||
toCanonicalJsonValue(value, new WeakSet<object>()),
|
||||
);
|
||||
if (json === undefined) {
|
||||
throw new TypeError("Cannot serialize undefined session compaction state");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function normalizeMessageForSourceHash(
|
||||
message: MessageWithMetadata,
|
||||
): MessageWithMetadata {
|
||||
if (message.role !== "user") {
|
||||
return message;
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
...message,
|
||||
content: formatDisplayUserInput(message.content),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { ...part, text: formatDisplayUserInput(part.text) }
|
||||
: part,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function messageBoundaryKey(message: MessageWithMetadata | undefined): string {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
const normalized = normalizeMessageForSourceHash(message);
|
||||
if (typeof normalized.id === "string" && normalized.id.trim()) {
|
||||
return `id:${normalized.id.trim()}`;
|
||||
}
|
||||
if (typeof normalized.ts === "number" && Number.isFinite(normalized.ts)) {
|
||||
return `ts:${normalized.role}:${normalized.ts}`;
|
||||
}
|
||||
return `content:${normalized.role}:${JSON.stringify(normalized.content)}`;
|
||||
}
|
||||
|
||||
function sourcePrefixHash(
|
||||
messages: readonly MessageWithMetadata[],
|
||||
count = messages.length,
|
||||
): string {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("cline-session-compaction-source-v1\n");
|
||||
hash.update(`${count}\n`);
|
||||
for (const message of messages.slice(0, count)) {
|
||||
hash.update(canonicalJson(normalizeMessageForSourceHash(message)));
|
||||
hash.update("\n");
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createSessionCompactionState(input: {
|
||||
sourceMessages: readonly MessageWithMetadata[];
|
||||
compactedMessages: readonly MessageWithMetadata[];
|
||||
conversationId?: string;
|
||||
systemPrompt?: string;
|
||||
updatedAt?: string;
|
||||
}): SessionCompactionState {
|
||||
const lastSourceMessage = input.sourceMessages.at(-1);
|
||||
const sourceLastMessageKey = messageBoundaryKey(lastSourceMessage);
|
||||
return SessionCompactionStateSchema.parse({
|
||||
version: 1,
|
||||
updated_at: input.updatedAt ?? new Date().toISOString(),
|
||||
...(input.conversationId?.trim()
|
||||
? { conversation_id: input.conversationId.trim() }
|
||||
: {}),
|
||||
source_message_count: input.sourceMessages.length,
|
||||
source_prefix_hash: sourcePrefixHash(input.sourceMessages),
|
||||
...(sourceLastMessageKey
|
||||
? { source_last_message_key: sourceLastMessageKey }
|
||||
: {}),
|
||||
messages: cloneMessages(input.compactedMessages),
|
||||
...(input.systemPrompt !== undefined
|
||||
? { system_prompt: input.systemPrompt }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function projectSessionCompactionState(
|
||||
state: SessionCompactionState,
|
||||
sourceMessages: readonly MessageWithMetadata[],
|
||||
): MessageWithMetadata[] | undefined {
|
||||
if (state.source_message_count > sourceMessages.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.source_prefix_hash) {
|
||||
if (
|
||||
sourcePrefixHash(sourceMessages, state.source_message_count) !==
|
||||
state.source_prefix_hash
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
} else if (state.source_message_count > 0 && state.source_last_message_key) {
|
||||
const boundary = sourceMessages[state.source_message_count - 1];
|
||||
if (messageBoundaryKey(boundary) !== state.source_last_message_key) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
...cloneMessages(state.messages),
|
||||
...cloneMessages(sourceMessages.slice(state.source_message_count)),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseSessionCompactionState(
|
||||
value: unknown,
|
||||
): SessionCompactionState | undefined {
|
||||
const parsed = SessionCompactionStateSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const SessionManifestSchema = z.object({
|
||||
prompt: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
messages_path: z.string().min(1).optional(),
|
||||
compaction_path: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type SessionManifest = z.infer<typeof SessionManifestSchema>;
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface CreateRootSessionWithArtifactsInput {
|
||||
export interface RootSessionArtifacts {
|
||||
manifestPath: string;
|
||||
messagesPath: string;
|
||||
compactionPath?: string;
|
||||
manifest: SessionManifest;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SqliteSessionStore } from "../../services/storage/sqlite-session-store";
|
||||
import { SessionSource } from "../../types/common";
|
||||
import { createSessionCompactionState } from "../models/session-compaction";
|
||||
import { FileSessionService } from "../services/file-session-service";
|
||||
import { CoreSessionService } from "../services/session-service";
|
||||
|
||||
@@ -32,6 +39,149 @@ describe("UnifiedSessionPersistenceService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("persists compaction state as a separate session artifact", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-artifact-"));
|
||||
tempDirs.push(sessionsDir);
|
||||
const service = new FileSessionService(sessionsDir);
|
||||
const sessionId = "session-with-compaction";
|
||||
const artifacts = await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: false,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "full transcript" },
|
||||
];
|
||||
const compactedMessages = [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages,
|
||||
conversationId: "conv-1",
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await service.persistSessionMessages(sessionId, sourceMessages);
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
|
||||
const messagesPayload = JSON.parse(
|
||||
readFileSync(artifacts.messagesPath, "utf8"),
|
||||
) as { messages?: unknown[] };
|
||||
const compactionPayload = JSON.parse(
|
||||
readFileSync(artifacts.compactionPath ?? "", "utf8"),
|
||||
) as { messages?: unknown[]; source_message_count?: number };
|
||||
expect(messagesPayload.messages).toHaveLength(1);
|
||||
expect(compactionPayload).toMatchObject({
|
||||
source_message_count: 1,
|
||||
messages: compactedMessages,
|
||||
});
|
||||
await expect(
|
||||
service.readSessionCompactionState(sessionId),
|
||||
).resolves.toMatchObject({
|
||||
source_message_count: 1,
|
||||
messages: compactedMessages,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes persisted compaction state without mutating canonical messages", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-delete-"));
|
||||
tempDirs.push(sessionsDir);
|
||||
const service = new FileSessionService(sessionsDir);
|
||||
const sessionId = "session-delete-compaction";
|
||||
const artifacts = await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: false,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "full transcript" },
|
||||
];
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await service.persistSessionMessages(sessionId, sourceMessages);
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
await service.deleteSessionCompactionState(sessionId);
|
||||
|
||||
expect(existsSync(artifacts.messagesPath)).toBe(true);
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(false);
|
||||
await expect(
|
||||
service.readSessionCompactionState(sessionId),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists compaction state without backfilling old manifests during path resolution", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-old-manifest-"));
|
||||
tempDirs.push(sessionsDir);
|
||||
const service = new FileSessionService(sessionsDir);
|
||||
const sessionId = "session-old-manifest";
|
||||
const artifacts = await service.createRootSessionWithArtifacts({
|
||||
sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: process.pid,
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: false,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(artifacts.manifestPath, "utf8"),
|
||||
) as {
|
||||
compaction_path?: string;
|
||||
};
|
||||
delete manifest.compaction_path;
|
||||
writeFileSync(
|
||||
artifacts.manifestPath,
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const state = createSessionCompactionState({
|
||||
sourceMessages: [
|
||||
{ id: "u1", role: "user" as const, content: "full transcript" },
|
||||
],
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(true);
|
||||
expect(
|
||||
JSON.parse(readFileSync(artifacts.manifestPath, "utf8")),
|
||||
).not.toHaveProperty("compaction_path");
|
||||
});
|
||||
|
||||
sqliteIt(
|
||||
"reconciles dead running sessions into failed manifests with terminal markers",
|
||||
async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
SessionPersistenceAdapter,
|
||||
StoredMessageWithMetadata,
|
||||
} from "../../types/session";
|
||||
import type { SessionCompactionState } from "../models/session-compaction";
|
||||
import type { SessionRow } from "../models/session-row";
|
||||
import { SessionManifestStore } from "../stores/session-manifest-store";
|
||||
import { TeamChildSessionManager } from "../team";
|
||||
@@ -109,6 +110,8 @@ export class UnifiedSessionPersistenceService {
|
||||
providedId.length > 0 ? providedId : `${Date.now()}_${nanoid(5)}`;
|
||||
const messagesPath =
|
||||
this.manifestStore.artifacts.sessionMessagesPath(sessionId);
|
||||
const compactionPath =
|
||||
this.manifestStore.artifacts.sessionCompactionPath(sessionId);
|
||||
const manifestPath =
|
||||
this.manifestStore.artifacts.sessionManifestPath(sessionId);
|
||||
const metadata = resolveMetadataWithTitle({
|
||||
@@ -134,6 +137,7 @@ export class UnifiedSessionPersistenceService {
|
||||
prompt: input.prompt?.trim() || undefined,
|
||||
metadata,
|
||||
messages_path: messagesPath,
|
||||
compaction_path: compactionPath,
|
||||
};
|
||||
|
||||
await this.adapter.upsertSession({
|
||||
@@ -172,7 +176,7 @@ export class UnifiedSessionPersistenceService {
|
||||
startedAt,
|
||||
);
|
||||
this.manifestStore.writeSessionManifest(manifestPath, manifest);
|
||||
return { manifestPath, messagesPath, manifest };
|
||||
return { manifestPath, messagesPath, compactionPath, manifest };
|
||||
}
|
||||
|
||||
async updateSessionStatus(
|
||||
@@ -320,6 +324,23 @@ export class UnifiedSessionPersistenceService {
|
||||
);
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
return await this.manifestStore.readSessionCompactionState(sessionId);
|
||||
}
|
||||
|
||||
async persistSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<void> {
|
||||
await this.manifestStore.persistSessionCompactionState(sessionId, state);
|
||||
}
|
||||
|
||||
async deleteSessionCompactionState(sessionId: string): Promise<void> {
|
||||
await this.manifestStore.deleteSessionCompactionState(sessionId);
|
||||
}
|
||||
|
||||
applySubagentStatus(
|
||||
subSessionId: string,
|
||||
event: HookEventPayload,
|
||||
@@ -547,6 +568,9 @@ export class UnifiedSessionPersistenceService {
|
||||
children.map(async (child) => {
|
||||
await deleteCheckpointRefs(child.cwd, child.sessionId);
|
||||
unlinkIfExists(child.messagesPath);
|
||||
await this.manifestStore.deleteSessionCompactionState(
|
||||
child.sessionId,
|
||||
);
|
||||
unlinkIfExists(
|
||||
this.manifestStore.artifacts.sessionManifestPath(
|
||||
child.sessionId,
|
||||
@@ -561,6 +585,7 @@ export class UnifiedSessionPersistenceService {
|
||||
await deleteCheckpointRefs(row.cwd, id);
|
||||
|
||||
unlinkIfExists(row.messagesPath);
|
||||
await this.manifestStore.deleteSessionCompactionState(id);
|
||||
unlinkIfExists(this.manifestStore.artifacts.sessionManifestPath(id, false));
|
||||
if (row.isSubagent) {
|
||||
this.manifestStore.artifacts.removeSessionDirIfEmpty(id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import type { BasicLogger } from "@cline/shared";
|
||||
@@ -20,11 +22,72 @@ import type {
|
||||
SessionPersistenceAdapter,
|
||||
StoredMessageWithMetadata,
|
||||
} from "../../types/session";
|
||||
import {
|
||||
parseSessionCompactionState,
|
||||
type SessionCompactionState,
|
||||
SessionCompactionStateSchema,
|
||||
} from "../models/session-compaction";
|
||||
import {
|
||||
type SessionManifest,
|
||||
SessionManifestSchema,
|
||||
} from "../models/session-manifest";
|
||||
|
||||
async function fsyncBestEffort(path: string): Promise<void> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(path, "r");
|
||||
await handle.sync();
|
||||
} catch {
|
||||
// Directory fsync is not available on all platforms/filesystems.
|
||||
} finally {
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Best-effort durability only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomic(path: string, contents: string): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(tempPath, "w");
|
||||
await handle.writeFile(contents, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await rename(tempPath, path);
|
||||
await fsyncBestEffort(dirname(path));
|
||||
} catch (error) {
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Preserve the original write error.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(tempPath, { force: true });
|
||||
} catch {
|
||||
// Preserve the original write error.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export class SessionManifestStore {
|
||||
readonly artifacts: SessionArtifacts;
|
||||
|
||||
@@ -135,6 +198,49 @@ export class SessionManifestStore {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCompactionPath(sessionId: string): string {
|
||||
const { manifest } = this.readManifestFile(sessionId);
|
||||
return (
|
||||
manifest?.compaction_path?.trim() ||
|
||||
this.artifacts.sessionCompactionPath(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
async readSessionCompactionState(
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined> {
|
||||
const path = this.resolveCompactionPath(sessionId);
|
||||
try {
|
||||
return parseSessionCompactionState(
|
||||
JSON.parse(await readFile(path, "utf8")) as unknown,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
this.logger?.debug("Ignoring invalid session compaction state", {
|
||||
sessionId,
|
||||
path,
|
||||
error,
|
||||
recovery: "Canonical history is unchanged; deleting the sidecar is safe.",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async persistSessionCompactionState(
|
||||
sessionId: string,
|
||||
state: SessionCompactionState,
|
||||
): Promise<void> {
|
||||
const path = this.resolveCompactionPath(sessionId);
|
||||
const payload = SessionCompactionStateSchema.parse(state);
|
||||
await writeFileAtomic(path, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async deleteSessionCompactionState(sessionId: string): Promise<void> {
|
||||
await rm(this.resolveCompactionPath(sessionId), { force: true });
|
||||
}
|
||||
|
||||
appendStaleSessionHookLog(
|
||||
detectedAt: string,
|
||||
sessionId: string,
|
||||
|
||||
@@ -64,8 +64,32 @@ export interface CoreCompactionContext {
|
||||
utilizationRatio: number;
|
||||
}
|
||||
|
||||
// Mirrors BudgetPolicyIntent in extensions/context/budget-projection/types.ts.
|
||||
// Keep this public API type decoupled from the internal projection module.
|
||||
export type CoreCompactionBudgetPolicyIntent =
|
||||
| "agentic_summary"
|
||||
| "basic_compaction_projection"
|
||||
| "normal_provider_request";
|
||||
|
||||
// Mirrors LiveTailHandling in extensions/context/budget-projection/types.ts.
|
||||
// Keep this public API type decoupled from the internal projection module.
|
||||
export type CoreCompactionLiveTailHandling =
|
||||
| "included_verbatim"
|
||||
| "included_degraded"
|
||||
| "summarized_as_context"
|
||||
| "omitted_with_warning"
|
||||
| "preserved_out_of_band";
|
||||
|
||||
export interface CoreCompactionBudgetMetadata {
|
||||
policyIntent: CoreCompactionBudgetPolicyIntent;
|
||||
actionCount: number;
|
||||
warningCount: number;
|
||||
liveTailHandling: CoreCompactionLiveTailHandling;
|
||||
}
|
||||
|
||||
export interface CoreCompactionResult {
|
||||
messages: MessageWithMetadata[];
|
||||
budget?: CoreCompactionBudgetMetadata;
|
||||
}
|
||||
|
||||
export interface CoreCompactionSummarizerConfig {
|
||||
@@ -74,6 +98,13 @@ export interface CoreCompactionSummarizerConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Optional pre-resolved model metadata for the summarizer. Supplying either
|
||||
* this or `knownModels` lets agentic compaction budget summary input against
|
||||
* the summarizer model's actual context window instead of falling back to the
|
||||
* active model's window.
|
||||
*/
|
||||
modelInfo?: ModelInfo;
|
||||
knownModels?: Record<string, ModelInfo>;
|
||||
providerConfig?: ProviderConfig;
|
||||
maxOutputTokens?: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentFinishReason } from "@cline/shared";
|
||||
import type { SessionAccumulatedUsage } from "../runtime/host/runtime-host";
|
||||
import type { BuiltRuntime } from "../runtime/orchestration/session-runtime";
|
||||
import type { SessionRuntime } from "../runtime/orchestration/session-runtime-orchestrator";
|
||||
import type { SessionCompactionState } from "../session/models/session-compaction";
|
||||
import type { SessionRow } from "../session/models/session-row";
|
||||
import type { RootSessionArtifacts } from "../session/services/session-service";
|
||||
import type { SessionSource, SessionStatus } from "./common";
|
||||
@@ -26,6 +27,8 @@ export type ActiveSession = {
|
||||
aborting: boolean;
|
||||
interactive: boolean;
|
||||
persistedMessages?: LlmsProviders.MessageWithMetadata[];
|
||||
compactionState?: SessionCompactionState;
|
||||
compactionStateWriteQueue?: Promise<void>;
|
||||
activeTeamRunIds: Set<string>;
|
||||
pendingTeamRunUpdates: TeamRunUpdate[];
|
||||
teamRunWaiters: Array<() => void>;
|
||||
|
||||
@@ -435,9 +435,9 @@ export interface AgentRuntimeConfig {
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult> | ToolApprovalResult;
|
||||
/**
|
||||
* Optional host-owned context pipeline that can rewrite the transcript
|
||||
* before each model request. When it returns messages, the runtime replaces
|
||||
* its in-memory transcript so compaction persists into the final run result.
|
||||
* Optional host-owned context pipeline that can project the transcript before
|
||||
* each model request. Returned messages affect the provider request only; the
|
||||
* runtime's canonical in-memory transcript remains append-only.
|
||||
*/
|
||||
prepareTurn?: (
|
||||
context: AgentRuntimePrepareTurnContext,
|
||||
|
||||
@@ -162,7 +162,9 @@ export interface AgentNoticeEvent extends AgentEventMetadata {
|
||||
| "completion_without_submit"
|
||||
| "tool_execution_failed"
|
||||
| "mistake_limit"
|
||||
| "auto_compaction";
|
||||
| "auto_compaction"
|
||||
| "manual_compaction"
|
||||
| "compaction_budget_emergency";
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -800,8 +802,9 @@ export interface AgentConfig {
|
||||
*/
|
||||
logger?: BasicLogger;
|
||||
/**
|
||||
* Optional callback that can rewrite the turn input before each model call.
|
||||
* This is the primary seam for host-owned context pipelines.
|
||||
* Optional callback that can project the turn input before each model call.
|
||||
* Returned messages affect the provider request only; the canonical runtime
|
||||
* transcript remains append-only.
|
||||
*/
|
||||
prepareTurn?: (
|
||||
context: AgentPrepareTurnContext,
|
||||
|
||||
@@ -313,6 +313,8 @@ export type HubCommandName =
|
||||
| "session.restore"
|
||||
| "session.delete"
|
||||
| "session.update"
|
||||
| "session.compaction.get"
|
||||
| "session.compaction.update"
|
||||
| "session.pending_prompts"
|
||||
| "session.update_pending_prompt"
|
||||
| "session.remove_pending_prompt"
|
||||
|
||||
@@ -344,12 +344,6 @@ export type { RuntimeEnv } from "./session/runtime-env";
|
||||
export * from "./session/workspace";
|
||||
export * from "./team";
|
||||
export { createTool } from "./tools/create";
|
||||
export type { OAuthProviderId } from "./types/auth";
|
||||
export {
|
||||
AUTH_ERROR_PATTERNS,
|
||||
isLikelyAuthError,
|
||||
isOAuthProviderId,
|
||||
OAUTH_PROVIDER_IDS,
|
||||
} from "./types/auth";
|
||||
export { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "./types/auth";
|
||||
// VCR is Node-only (uses node:fs, node:path), excluded from browser build
|
||||
export type { VcrRecording } from "./types/vcr";
|
||||
|
||||
@@ -395,11 +395,5 @@ export * from "./session/workspace";
|
||||
export * from "./team";
|
||||
export { createTool } from "./tools/create";
|
||||
export * from "./types";
|
||||
export type { OAuthProviderId } from "./types/auth";
|
||||
export {
|
||||
AUTH_ERROR_PATTERNS,
|
||||
isLikelyAuthError,
|
||||
isOAuthProviderId,
|
||||
OAUTH_PROVIDER_IDS,
|
||||
} from "./types/auth";
|
||||
export { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "./types/auth";
|
||||
export { initVcr } from "./vcr";
|
||||
|
||||
@@ -208,8 +208,6 @@ export interface ProviderModelsResponse {
|
||||
models: ProviderModel[];
|
||||
}
|
||||
|
||||
import type { OAuthProviderId } from "../types/auth";
|
||||
|
||||
export const ProviderCapabilitySchema = z.enum([
|
||||
"reasoning",
|
||||
"prompt-cache",
|
||||
@@ -424,6 +422,6 @@ export type ProviderActionRequest =
|
||||
| ClineAccountActionRequest;
|
||||
|
||||
export interface ProviderOAuthLoginResponse {
|
||||
provider: OAuthProviderId;
|
||||
provider: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
/**
|
||||
* Canonical list of OAuth provider IDs managed by the platform.
|
||||
* Derive sets, types, and guards from this single source of truth.
|
||||
*/
|
||||
export const OAUTH_PROVIDER_IDS = ["cline", "oca", "openai-codex"] as const;
|
||||
|
||||
export type OAuthProviderId = (typeof OAUTH_PROVIDER_IDS)[number];
|
||||
|
||||
/**
|
||||
* Check whether a provider ID is a managed OAuth provider.
|
||||
*/
|
||||
export function isOAuthProviderId(
|
||||
providerId: string,
|
||||
): providerId is OAuthProviderId {
|
||||
return (OAUTH_PROVIDER_IDS as readonly string[]).includes(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error‑message sub-strings that indicate an auth / credential failure.
|
||||
* Used to decide whether a failed API call should trigger an OAuth refresh.
|
||||
* Used with provider auth handlers to decide whether a failed API call should
|
||||
* trigger an OAuth refresh.
|
||||
*/
|
||||
export const AUTH_ERROR_PATTERNS = [
|
||||
"401",
|
||||
@@ -30,11 +14,9 @@ export const AUTH_ERROR_PATTERNS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Returns `true` when `error` looks like an authentication failure
|
||||
* *and* the provider is a managed OAuth provider.
|
||||
* Returns `true` when `error` looks like an authentication failure.
|
||||
*/
|
||||
export function isLikelyAuthError(error: unknown, providerId: string): boolean {
|
||||
if (!isOAuthProviderId(providerId)) return false;
|
||||
export function isLikelyAuthError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error ? error.message.toLowerCase() : String(error);
|
||||
return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern));
|
||||
|
||||
Reference in New Issue
Block a user