Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4a364e08 | ||
|
|
fee4fb96f2 | ||
|
|
eb18ce3407 | ||
|
|
9fb1e6fc25 | ||
|
|
40f1d75bd2 | ||
|
|
b18de0904f | ||
|
|
de1d2a322c | ||
|
|
245a7d0ccb | ||
|
|
23f2197c53 | ||
|
|
9df2876c89 | ||
|
|
b63c738c22 | ||
|
|
bbacedd437 | ||
|
|
4096eca8a1 | ||
|
|
2f0f78bb8e | ||
|
|
62ba65397f | ||
|
|
79bf1e8c48 | ||
|
|
ffd65af086 | ||
|
|
119fa4ea03 | ||
|
|
40a7e6526d | ||
|
|
3d5070705c | ||
|
|
5a3b870d85 | ||
|
|
43bb575b6d | ||
|
|
50f5664683 | ||
|
|
8a520150f8 | ||
|
|
7e8063857b | ||
|
|
8ff5f22cf9 | ||
|
|
f5af82140b | ||
|
|
fc28a5fe33 | ||
|
|
5746f74ee7 | ||
|
|
d56cd10a6c | ||
|
|
6abb7cee76 | ||
|
|
afa1b01be1 | ||
|
|
c21b17255b | ||
|
|
dac3b35ba4 | ||
|
|
adbfbd97d3 | ||
|
|
952df213ee | ||
|
|
d7cb79aea5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Warn when attached images will be ignored because the selected model does not support image input: image thumbnails get a warning badge and the composer offers to switch to an image-capable model, instead of the images being silently dropped before the API call
|
||||
@@ -61,8 +61,9 @@ Emission ownership:
|
||||
|
||||
- `user.extension_activated`: emitted **once per host process** by host-specific helpers
|
||||
(`captureCliExtensionActivated` for the CLI, `captureExtensionActivated` for VS Code).
|
||||
- `workspace.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
|
||||
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
|
||||
- `workspace.initialized` / `workspace.init_error`: emitted by a de-duplicated emitter in
|
||||
`prepareLocalRuntimeBootstrap`. A dedicated host emits once per workspace; a shared Hub emits
|
||||
once per client surface and workspace. Hosts must NOT re-emit these.
|
||||
- `workspace.path_resolved`: emitted from default tool executors **only when**
|
||||
`WorkspaceManager` exposes more than one root.
|
||||
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
|
||||
@@ -104,6 +105,19 @@ hub-backed session, so the daemon must own its own `ITelemetryService`. It build
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
|
||||
The Hub transport forwards the serializable `ExtensionContext.client` and
|
||||
`ExtensionContext.user` values with session create/restore requests. The daemon wraps its
|
||||
process-owned service with `createClientScopedTelemetryService()` so lifecycle events use the
|
||||
originating client's `cline_type`, platform/version, and current account/organization without
|
||||
mutating the singleton shared by concurrent clients. Keep canonical task fields named
|
||||
`provider` and `model`; do not reintroduce host-specific aliases such as `apiProvider` or
|
||||
`modelId` for `task.created`, `task.restarted`, or `task.completed`.
|
||||
|
||||
`UserContext.distinctId` may be an anonymous machine ID. Set `UserContext.accountId` to the
|
||||
authenticated account ID (or `null` for an explicitly signed-out client) whenever a client
|
||||
forwards user context; this prevents machine IDs and stale daemon identity from becoming
|
||||
`user_id` / `organization_id` on task events.
|
||||
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Cline</h1>
|
||||
|
||||
<p align="center">
|
||||
The open source coding agent in your IDE and terminal.
|
||||
The open source coding agent in your IDE, terminal, and desktop.
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -44,8 +44,7 @@ The open source coding agent in your IDE and terminal.
|
||||
### CLI
|
||||
|
||||
Run Cline in your terminal.
|
||||
Interactive chat or fully headless
|
||||
for CI/CD and scripting.
|
||||
Interactive chat or fully headless for CI/CD and scripting.
|
||||
|
||||
```
|
||||
npm i -g cline
|
||||
@@ -57,17 +56,13 @@ npm i -g cline
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
|
||||
### Kanban
|
||||
### Desktop App
|
||||
|
||||
Run many agents in parallel from a
|
||||
web-based task board. Each card gets its own
|
||||
worktree, auto-commit, and dependency chains.
|
||||
Cline as a native app for macOS and Windows.
|
||||
Run agent sessions in any folder, schedule
|
||||
routines, and manage models, plugins, and MCP servers.
|
||||
|
||||
```
|
||||
npm i -g kanban
|
||||
```
|
||||
|
||||
<a href="https://github.com/cline/kanban">Learn more</a>
|
||||
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -108,7 +103,7 @@ the JetBrains family.
|
||||
|
||||
### SDK
|
||||
|
||||
Build your own AI agents and integrations powered by the same engine that runs the CLI, Kanban, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
|
||||
Build your own AI agents and integrations powered by the same engine that runs the CLI, desktop app, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
|
||||
|
||||
```
|
||||
npm install @cline/sdk
|
||||
@@ -131,8 +126,8 @@ npm install @cline/sdk
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
|
||||
|
||||
## Edits Code Across Your Project
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
|
||||
// Note: discord.js@14 declares undici ^6.27.0, but the root package.json
|
||||
// override ("undici": ">=7.29.0 <8") forces undici 7.x for CVE-2026-1525.
|
||||
import type { ChatStartSessionRequest } from "@cline/core";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
@@ -158,38 +160,6 @@ export async function createClineAccountService(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active organization so headless runs and the hub daemon can
|
||||
* attach it to telemetry identity. Personal account clears stale org fields.
|
||||
*/
|
||||
function persistClineOrganizationContext(
|
||||
activeOrganization: ClineAccountOrganization | null,
|
||||
userId: string,
|
||||
): void {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: persisted.auth?.accountId ?? userId,
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
@@ -213,16 +183,12 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
const accountContext = resolveClineAccountTelemetryIdentity(user);
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
persistClineAccountTelemetryIdentity(
|
||||
new ProviderSettingsManager(),
|
||||
accountContext,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -15,11 +16,10 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
checkLocalCliInstalled,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
import { getProviderSection } from "../../utils/provider-sections";
|
||||
import { canContinueLocalCliSetup } from "../../views/onboarding/model";
|
||||
import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
@@ -82,7 +83,7 @@ export function ProviderPickerContent(
|
||||
// just a model id and base URL) still render as configured.
|
||||
isConfigured: p.enabled === true,
|
||||
isOAuth: isOAuthProvider(p.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(p.id),
|
||||
isLocalAuth: isLocalAuthProvider(p.id),
|
||||
capabilities: p.capabilities,
|
||||
}));
|
||||
setProviders(providerItems);
|
||||
@@ -654,29 +655,25 @@ export function ProviderConfigInputContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexCliStatusContent(
|
||||
export function LocalCliStatusContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
cli?: ProviderLocalCli;
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const { resolve, dismiss, dialogId, cli, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [status, setStatus] = useState<LocalCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!cli) return;
|
||||
setStatus(undefined);
|
||||
setChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
checkLocalCliInstalled(cli)
|
||||
.then(setStatus)
|
||||
.catch((error: unknown) => {
|
||||
setStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => setChecking(false));
|
||||
}, []);
|
||||
}, [cli]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -691,7 +688,7 @@ export function CodexCliStatusContent(
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" && status?.installed) {
|
||||
if (key.name === "return" && canContinueLocalCliSetup(cli, status)) {
|
||||
resolve(true);
|
||||
}
|
||||
}, dialogId);
|
||||
@@ -702,31 +699,37 @@ export function CodexCliStatusContent(
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
{checking && <text fg="gray">Checking for Codex CLI...</text>}
|
||||
{checking && <text fg="gray">Checking for {providerName}...</text>}
|
||||
|
||||
{status?.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={palette.success}>
|
||||
{"\u25cf"} {providerName} installed
|
||||
</text>
|
||||
<text fg="gray">{status.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{status && !status.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="yellow">{providerName} was not found</text>
|
||||
<text fg="gray">{status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
{cli?.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {providerName} from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{status?.installed
|
||||
{cli
|
||||
? "Enter to continue, R to recheck, Esc to go back"
|
||||
: "R to recheck, Esc to go back"}
|
||||
: "Enter to continue, Esc to go back"}
|
||||
</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -23,6 +23,9 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
};
|
||||
const availableWidth = Math.max(1, width - 4);
|
||||
const maxWidth = Math.min(44, availableWidth);
|
||||
// Border and horizontal padding take four columns. An explicit width (not
|
||||
// maxWidth) is what makes the text wrap instead of clipping at the edge.
|
||||
const boxWidth = Math.min(maxWidth, props.toast.message.length + 4);
|
||||
const right = width < 32 ? 0 : 2;
|
||||
const color = variantColor[props.toast.variant];
|
||||
|
||||
@@ -32,7 +35,7 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
zIndex={100}
|
||||
top={1}
|
||||
right={right}
|
||||
maxWidth={maxWidth}
|
||||
width={boxWidth}
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={color}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isClineProvider } from "@cline/shared";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
|
||||
import { getLocalCliInfo } from "../../utils/local-cli";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
@@ -20,8 +20,8 @@ import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderOption,
|
||||
LocalCliStatusContent,
|
||||
OAuthApiKeyInputContent,
|
||||
OAuthLoginContent,
|
||||
type OAuthLoginResult,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type ThinkingLevel,
|
||||
ThinkingLevelContent,
|
||||
} from "../components/model-selector/model-selector";
|
||||
import { resolveProviderSetupRoute } from "../views/onboarding/model";
|
||||
|
||||
export interface OpenModelSelectorOptions {
|
||||
onCancel?: () => Promise<void> | void;
|
||||
@@ -178,6 +179,9 @@ async function runProviderChange(
|
||||
async () => await getProviderDisplayName(newProviderId),
|
||||
);
|
||||
const existingSettings = manager.getProviderSettings(newProviderId);
|
||||
const needsLocalCliSetup =
|
||||
resolveProviderSetupRoute(newProviderId) === "local_cli";
|
||||
const localCliProvider = getLocalCliInfo(newProviderId);
|
||||
|
||||
// Manual API key entry is the escape hatch for when OAuth login isn't
|
||||
// working; only the Cline providers accept a dashboard API key.
|
||||
@@ -246,12 +250,16 @@ async function runProviderChange(
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
} else if (needsLocalCliSetup) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<CodexCliStatusContent {...ctx} providerName={displayName} />
|
||||
<LocalCliStatusContent
|
||||
{...ctx}
|
||||
cli={localCliProvider}
|
||||
providerName={displayName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
getIndividualPlanFeatures,
|
||||
} from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
checkLocalCliInstalled,
|
||||
getLocalCliInfo,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
@@ -59,6 +60,7 @@ import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
@@ -66,6 +68,7 @@ import {
|
||||
type OnboardingStep,
|
||||
type ProviderEntry,
|
||||
type ReasoningEffort,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
type ThinkingLevel,
|
||||
toModelEntriesFromKnownModels,
|
||||
@@ -103,6 +106,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [authError, setAuthError] = useState("");
|
||||
const [activeProviderId, setActiveProviderId] = useState("");
|
||||
const [activeProviderName, setActiveProviderName] = useState("");
|
||||
const localCli = useMemo(
|
||||
() => getLocalCliInfo(activeProviderId),
|
||||
[activeProviderId],
|
||||
);
|
||||
const [byoFields, setByoFields] = useState<ProviderConfigFields["fields"]>(
|
||||
{},
|
||||
);
|
||||
@@ -110,10 +117,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
|
||||
const [byoFocusedField, setByoFocusedField] =
|
||||
useState<ProviderConfigFieldKey>("apiKey");
|
||||
const [codexCliStatus, setCodexCliStatus] = useState<
|
||||
CodexCliStatus | undefined
|
||||
const [localCliStatus, setLocalCliStatus] = useState<
|
||||
LocalCliStatus | undefined
|
||||
>();
|
||||
const [codexCliChecking, setCodexCliChecking] = useState(false);
|
||||
const [localCliChecking, setLocalCliChecking] = useState(false);
|
||||
const localCliProbeRef = useRef(0);
|
||||
const authAbortRef = useRef(false);
|
||||
|
||||
// Device code flow
|
||||
@@ -486,18 +494,23 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
.then(setCodexCliStatus)
|
||||
.catch((error: unknown) => {
|
||||
setCodexCliStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const refreshLocalCliStatus = useCallback((provider: ProviderLocalCli) => {
|
||||
// Probing spawns the provider's CLI, so a result can land long after the
|
||||
// user moved on. Two local-CLI providers share this single status, so an
|
||||
// unlabelled result could mark the selected provider ready off a probe of
|
||||
// the previous one (or block it off a stale failure). Only the newest
|
||||
// probe may write.
|
||||
const probeId = ++localCliProbeRef.current;
|
||||
const isCurrentProbe = () => localCliProbeRef.current === probeId;
|
||||
setLocalCliStatus(undefined);
|
||||
setLocalCliChecking(true);
|
||||
checkLocalCliInstalled(provider)
|
||||
.then((status) => {
|
||||
if (isCurrentProbe()) setLocalCliStatus(status);
|
||||
})
|
||||
.finally(() => setCodexCliChecking(false));
|
||||
.finally(() => {
|
||||
if (isCurrentProbe()) setLocalCliChecking(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
@@ -510,12 +523,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (provider.isLocalAuth || isOpenAICodexCliProvider(provider.id)) {
|
||||
if (resolveProviderSetupRoute(provider.id) === "local_cli") {
|
||||
setActiveProviderId(provider.id);
|
||||
setActiveProviderName(provider.name);
|
||||
setCodexCliStatus(undefined);
|
||||
setStep("codex_cli_setup");
|
||||
refreshCodexCliStatus();
|
||||
setStep("local_cli_setup");
|
||||
// Only providers that name a CLI have something to probe; the
|
||||
// rest reach the screen with readiness simply unknown.
|
||||
const localCliProvider = getLocalCliInfo(provider.id);
|
||||
if (localCliProvider) refreshLocalCliStatus(localCliProvider);
|
||||
return;
|
||||
}
|
||||
const config = getProviderConfigFields(provider.id);
|
||||
@@ -575,11 +590,17 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setByoFocusedField(firstField ?? "apiKey");
|
||||
setStep("byo_apikey");
|
||||
},
|
||||
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
|
||||
[providers, startOAuthFlow, refreshLocalCliStatus, providerSettingsManager],
|
||||
);
|
||||
|
||||
const saveCodexCliConfig = useCallback(() => {
|
||||
if (!codexCliStatus?.installed) {
|
||||
const recheckLocalCli = useCallback(() => {
|
||||
if (localCli) {
|
||||
refreshLocalCliStatus(localCli);
|
||||
}
|
||||
}, [localCli, refreshLocalCliStatus]);
|
||||
|
||||
const saveLocalCliConfig = useCallback(() => {
|
||||
if (!canContinueLocalCliSetup(localCli, localCliStatus)) {
|
||||
return;
|
||||
}
|
||||
saveLocalProviderSettings(providerSettingsManager, {
|
||||
@@ -588,7 +609,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
transitionToModelPicker(activeProviderId);
|
||||
}, [
|
||||
activeProviderId,
|
||||
codexCliStatus,
|
||||
localCli,
|
||||
localCliStatus,
|
||||
providerSettingsManager,
|
||||
transitionToModelPicker,
|
||||
]);
|
||||
@@ -798,13 +820,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
deviceAbortRef.current = true;
|
||||
},
|
||||
resetAuth,
|
||||
refreshCodexCliStatus,
|
||||
refreshLocalCliStatus: recheckLocalCli,
|
||||
startOAuthFlow,
|
||||
startDeviceCodeFlow,
|
||||
selectProvider,
|
||||
loadModelsForProvider,
|
||||
saveClineModelSelection,
|
||||
saveCodexCliConfig,
|
||||
saveLocalCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
saveThinkingLevel,
|
||||
@@ -820,8 +842,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
byoFields,
|
||||
byoFocusedField,
|
||||
byoValues,
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
localCli,
|
||||
localCliChecking,
|
||||
localCliStatus,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
@@ -860,7 +883,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providersLoading,
|
||||
recommendedLoading: recommended.loading,
|
||||
saveByoConfig,
|
||||
saveCodexCliConfig,
|
||||
saveLocalCliConfig,
|
||||
saveCustomModelId,
|
||||
selectedModelName,
|
||||
step,
|
||||
|
||||
@@ -51,13 +51,13 @@ export function useOnboardingKeyboard(input: {
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
refreshCodexCliStatus: () => void;
|
||||
refreshLocalCliStatus: () => void;
|
||||
startOAuthFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveLocalCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
saveThinkingLevel: (level: ThinkingLevel) => void;
|
||||
@@ -98,7 +98,7 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (input.step === "local_cli_setup") {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
@@ -227,13 +227,13 @@ export function useOnboardingKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (input.step === "local_cli_setup") {
|
||||
if (key.name === "r") {
|
||||
input.refreshCodexCliStatus();
|
||||
input.refreshLocalCliStatus();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
input.saveCodexCliConfig();
|
||||
input.saveLocalCliConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getLocalCliInfo } from "../../../utils/local-cli";
|
||||
|
||||
vi.mock("../../../utils/local-cli", () => ({
|
||||
getLocalCliInfo: () => undefined,
|
||||
}));
|
||||
|
||||
import {
|
||||
canContinueLocalCliSetup,
|
||||
getMainMenuOptions,
|
||||
getOAuthProviderLabel,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
@@ -77,6 +85,20 @@ describe("onboarding model helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the Claude Code provider as local auth", () => {
|
||||
expect(
|
||||
toProviderEntry({
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
models: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "claude-code",
|
||||
isOAuth: false,
|
||||
isLocalAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps model names and reasoning support strictly", () => {
|
||||
expect(
|
||||
toModelEntry({
|
||||
@@ -166,3 +188,31 @@ describe("onboarding model helpers", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-auth setup routing", () => {
|
||||
// A provider can declare `local-auth` without naming a CLI we can probe.
|
||||
// Routing must follow the capability; the descriptor is only for probing.
|
||||
// Otherwise it falls through to the API-key form, which renders no fields
|
||||
// for a local-auth provider.
|
||||
it("routes a local-auth provider with no CLI descriptor to local setup", () => {
|
||||
expect(getLocalCliInfo("claude-code")).toBeUndefined();
|
||||
expect(resolveProviderSetupRoute("claude-code")).toBe("local_cli");
|
||||
});
|
||||
|
||||
it("routes OAuth and API-key providers unchanged", () => {
|
||||
expect(resolveProviderSetupRoute("anthropic")).toBe("api_key");
|
||||
});
|
||||
|
||||
// The probe only looks on PATH, while the runtime also accepts an explicit
|
||||
// pathToClaudeCodeExecutable and a bundled platform binary. A PATH miss
|
||||
// therefore means "not on PATH", not "unusable", so it must not block.
|
||||
it("lets the user continue when the CLI is not found on PATH", () => {
|
||||
const cli = { command: "claude", docsUrl: "https://example.invalid" };
|
||||
expect(
|
||||
canContinueLocalCliSetup(cli, {
|
||||
installed: false,
|
||||
reason: "The claude executable was not found on PATH.",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,14 @@ import type {
|
||||
ModelOperation,
|
||||
} from "@cline/shared";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
} from "../../../utils/provider-auth";
|
||||
|
||||
export type OnboardingStep =
|
||||
| "menu"
|
||||
@@ -13,7 +19,7 @@ export type OnboardingStep =
|
||||
| "device_code"
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "local_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
@@ -85,6 +91,35 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Which setup flow a provider needs. Keyed off how the provider authenticates,
|
||||
* so every caller routes the same way.
|
||||
*/
|
||||
export type ProviderSetupRoute = "oauth" | "local_cli" | "api_key";
|
||||
|
||||
export function resolveProviderSetupRoute(
|
||||
providerId: string,
|
||||
): ProviderSetupRoute {
|
||||
if (isOAuthProvider(providerId)) return "oauth";
|
||||
if (isLocalAuthProvider(providerId)) return "local_cli";
|
||||
return "api_key";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local-CLI setup screen lets the user connect.
|
||||
*/
|
||||
export function canContinueLocalCliSetup(
|
||||
_cli: ProviderLocalCli | undefined,
|
||||
_status: LocalCliStatus | undefined,
|
||||
): boolean {
|
||||
// The probe only looks on PATH, while the runtime also accepts an explicit
|
||||
// pathToClaudeCodeExecutable and a bundled platform binary, and Codex falls
|
||||
// back through `npx`. A PATH miss therefore means "not on PATH", not
|
||||
// "unusable", so the screen reports it without blocking — a provider that
|
||||
// really cannot start says so on the first turn, in its own words.
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMainMenuOptions(options?: {
|
||||
isClinePassEnabled?: boolean;
|
||||
}): MenuOption[] {
|
||||
@@ -174,7 +209,7 @@ export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isOAuth: isOAuthProvider(provider.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(provider.id),
|
||||
isLocalAuth: isLocalAuthProvider(provider.id),
|
||||
hasAuth:
|
||||
Boolean(provider.apiKey) || provider.oauthAccessTokenPresent === true,
|
||||
...(provider.capabilities ? { capabilities: provider.capabilities } : {}),
|
||||
|
||||
@@ -2,10 +2,10 @@ import "opentui-spinner/react";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
} from "../../../utils/codex-cli";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
ClineModelPicker,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -25,6 +25,7 @@ import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
@@ -362,18 +363,20 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingCodexCliScreen(props: {
|
||||
export function OnboardingLocalCliScreen(props: {
|
||||
activeProviderName: string;
|
||||
checking: boolean;
|
||||
cli?: ProviderLocalCli;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
mouse: MouseTrackerState;
|
||||
status?: CodexCliStatus;
|
||||
status?: LocalCliStatus;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const colors = useOnboardingColors();
|
||||
const installedStatus =
|
||||
props.status?.installed === true ? props.status : undefined;
|
||||
const canContinue = canContinueLocalCliSetup(props.cli, props.status);
|
||||
return (
|
||||
<OnboardingFrame
|
||||
compact={props.compact}
|
||||
@@ -386,31 +389,37 @@ export function OnboardingCodexCliScreen(props: {
|
||||
{props.checking && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">Checking for Codex CLI...</text>
|
||||
<text fg="gray">Checking for {props.activeProviderName}...</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{installedStatus && (
|
||||
<box flexDirection="column" gap={1} alignItems="center">
|
||||
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg={colors.success}>
|
||||
{"\u25cf"} {props.activeProviderName} installed
|
||||
</text>
|
||||
<text fg="gray">{installedStatus.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{props.status && !props.status.installed && (
|
||||
{props.cli && props.status && !props.status.installed && (
|
||||
<box flexDirection="column" gap={1} width={props.contentWidth}>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="yellow">{props.activeProviderName} was not found</text>
|
||||
<text fg="gray">{props.status.reason}</text>
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
{props.cli.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {props.activeProviderName} from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{props.cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{installedStatus
|
||||
{canContinue
|
||||
? "Enter to continue, R to recheck, Esc to go back, Ctrl+C to exit"
|
||||
: "R to recheck, Esc to go back, Ctrl+C to exit"}
|
||||
</em>
|
||||
|
||||
@@ -7,10 +7,10 @@ import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
OnboardingDoneScreen,
|
||||
OnboardingLocalCliScreen,
|
||||
OnboardingMainMenuScreen,
|
||||
OnboardingModelPickerScreen,
|
||||
OnboardingOAuthPendingScreen,
|
||||
@@ -83,15 +83,16 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "codex_cli_setup") {
|
||||
if (state.step === "local_cli_setup" && state.localCli) {
|
||||
return (
|
||||
<OnboardingCodexCliScreen
|
||||
<OnboardingLocalCliScreen
|
||||
activeProviderName={state.activeProviderName}
|
||||
checking={state.codexCliChecking}
|
||||
checking={state.localCliChecking}
|
||||
cli={state.localCli}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
mouse={mouse}
|
||||
status={state.codexCliStatus}
|
||||
status={state.localCliStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const OPENAI_CODEX_CLI_PROVIDER_ID = "openai-codex-cli";
|
||||
export const CODEX_CLI_INSTALL_URL = "https://developers.openai.com/codex/cli";
|
||||
|
||||
export type CodexCliStatus =
|
||||
| {
|
||||
installed: true;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function isOpenAICodexCliProvider(providerId: string): boolean {
|
||||
return providerId.trim().toLowerCase() === OPENAI_CODEX_CLI_PROVIDER_ID;
|
||||
}
|
||||
|
||||
export async function checkCodexCliInstalled(): Promise<CodexCliStatus> {
|
||||
try {
|
||||
const result = await execFileAsync("codex", ["--version"], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = (result.stdout || result.stderr).trim();
|
||||
return {
|
||||
installed: true,
|
||||
version: version || "codex",
|
||||
};
|
||||
} catch (error) {
|
||||
const details =
|
||||
error && typeof error === "object"
|
||||
? (error as { code?: unknown; message?: unknown })
|
||||
: undefined;
|
||||
const code = typeof details?.code === "string" ? details.code : "";
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
installed: false,
|
||||
reason: "The codex executable was not found on PATH.",
|
||||
};
|
||||
}
|
||||
const message =
|
||||
typeof details?.message === "string"
|
||||
? details.message
|
||||
: "Could not run codex --version.";
|
||||
return {
|
||||
installed: false,
|
||||
reason: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isLocalAuthProvider } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLocalCliInfo } from "./local-cli";
|
||||
|
||||
describe("local CLI providers", () => {
|
||||
it("reads the CLI a local-auth provider borrows credentials from", () => {
|
||||
expect(getLocalCliInfo("openai-codex-cli")).toEqual({
|
||||
command: "codex",
|
||||
docsUrl: "https://developers.openai.com/codex/cli",
|
||||
});
|
||||
expect(getLocalCliInfo("claude-code")).toEqual({
|
||||
command: "claude",
|
||||
docsUrl: "https://code.claude.com/docs/en/setup",
|
||||
});
|
||||
});
|
||||
|
||||
it("names no CLI for providers that authenticate with an API key", () => {
|
||||
expect(getLocalCliInfo("anthropic")).toBeUndefined();
|
||||
expect(getLocalCliInfo("openai-codex")).toBeUndefined();
|
||||
});
|
||||
|
||||
// Routing is keyed off the capability alone, so a local-auth provider whose
|
||||
// credentials come from somewhere unprobeable still reaches the local setup
|
||||
// screen instead of an empty API-key form.
|
||||
it("routes on the capability, not on knowing a CLI", () => {
|
||||
expect(isLocalAuthProvider("claude-code")).toBe(true);
|
||||
expect(isLocalAuthProvider("openai-codex-cli")).toBe(true);
|
||||
expect(isLocalAuthProvider("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { Llms } from "@cline/core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type ProviderLocalCli = Llms.ProviderLocalCli;
|
||||
|
||||
export type LocalCliStatus =
|
||||
| {
|
||||
installed: true;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The CLI a `local-auth` provider borrows credentials from, as declared in
|
||||
* the provider catalog. `undefined` for providers that name none — those are
|
||||
* connected without a readiness check rather than probing a guessed command.
|
||||
*/
|
||||
export function getLocalCliInfo(
|
||||
providerId: string,
|
||||
): ProviderLocalCli | undefined {
|
||||
return Llms.resolveProviderLocalCli(providerId);
|
||||
}
|
||||
|
||||
export async function checkLocalCliInstalled(
|
||||
cli: ProviderLocalCli,
|
||||
): Promise<LocalCliStatus> {
|
||||
try {
|
||||
const result = await execFileAsync(cli.command, ["--version"], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = (result.stdout || result.stderr).trim();
|
||||
return {
|
||||
installed: true,
|
||||
version: version || cli.command,
|
||||
};
|
||||
} catch (error) {
|
||||
const details = error as NodeJS.ErrnoException | undefined;
|
||||
if (details?.code === "ENOENT") {
|
||||
return {
|
||||
installed: false,
|
||||
reason: `The ${cli.command} executable was not found on PATH.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: false,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Could not run ${cli.command} --version.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
@@ -21,7 +22,7 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
export { isOAuthProvider };
|
||||
export { isLocalAuthProvider, isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.24
|
||||
|
||||
- Fixed the live chat stream doubling text and dropping messages mid-turn. The sidecar has two Hub sockets that both receive a session's events — ClineCore's own client and the observer client — and a session that streams without a local send first (a run already in flight when you open the task, a resumed run, a scheduled run) had every delta rendered twice. The observer's copy is now skipped whenever ClineCore is subscribed to the session, asked directly rather than inferred from a timer, so long commands, slow first tokens, and unanswered tool approvals cannot let a duplicate slip through ahead of the core copy. Separately, when the sidecar was replaced under a live webview (crash-respawn, Hub drain-and-replace, stale-sidecar swap) its stream counter restarted at 1 and the webview silently discarded everything until the new process counted past the old run — this dropped your own message bubbles and tool rows, not just assistant text, which is why rows appeared to vanish mid-turn and come back afterwards
|
||||
- Fixed a queued prompt's own message vanishing from the chat. When you queue a prompt behind a running turn, the runtime drains the queue just before it answers the previous send, so the previous turn's completion path replaced the whole transcript from a canonical read that predated your queued message — erasing your bubble and leaving the reply streaming in under no user message. That path now defers to the newer turn instead of treating the transcript as its own. Two symptoms rode on the same bug: the composer no longer drops out of its busy state while the queued reply is still pending, and a finished reasoning row now reads "Thought for Ns" instead of a stuck "Thinking" — live rows are stamped on the webview's clock, so a sidecar whose clock trails it (a remote Hub, the browser-dev setup) no longer produces a negative duration that gets dropped
|
||||
- Cline no longer stops silently mid-task when a model gets stuck repeating itself. The loop detector stops a run after 5 identical tool calls and the mistake tracker after 6 consecutive failures, but the desktop never registered a decision callback, so the run just ended and the composer went idle with no message. You are now asked how to continue — "Try a different approach" or "Stop this run" — and the guidance is steered into the running turn so the model knows why it was paused instead of repeating the same call
|
||||
- The `editor` tool's error message now names the file, says whether `old_text` was null or omitted, and states how to recover. Models that fill optional parameters with null (seen with kimi-k3) hit a terse "old_text is required" and re-sent the identical call until the loop detector stopped the run
|
||||
- Fixed your Cline Pass model selection being replaced when you start a new chat. Catalogs are discovery data, not validation — the bundled catalog can omit live Cline Pass models and refreshes can return partial lists, so a model missing from the catalog was treated as invalid and silently swapped for a default
|
||||
- Cline Desktop now has a custom title bar on Windows, with caption controls that follow the compact title-bar height in narrow windows and stay above overlays. The Windows taskbar icon was also updated
|
||||
- Token counts and costs now fill in for every session you can see. The sessions view only ever hydrated the four most recent rows, so every other row showed "-" and paging never asked for more; the visible page is now hydrated on demand, with reads capped and re-run when a session's status changes underneath them
|
||||
- Sessions imported from Claude Code, Codex, and opencode now say so in the chat, and their foreign history is summarized on the first resumed turn. Imported transcripts keep the source tool's own tool names and schemas, which a model continuing them may try to call — the summary runs once, the original transcript stays intact, and the "Thinking..." indicator reads "Summarizing the imported <tool> history..." while it happens
|
||||
- Fixed session history rendering empty when one session had many subagent or team-task children. Child rows always sort after the root that spawned them, so a single busy session could hide itself and every older session from the sidebar with no way to load more
|
||||
- Checkpoints no longer re-hash every untracked file before each message. Checkpoint creation rebuilt a throwaway git index each turn, so multi-GB untracked data blocked every message for seconds to minutes (~90s in one report on a cloud-synced Windows workspace). One snapshot index is now kept per session, so from the second turn the cost is roughly git process overhead. Snapshot contents are byte-identical to before
|
||||
- Commands that background a child process (`cmd &`, `nohup`, and the same from Git Bash) no longer hang until the timeout. The inherited stdio pipes stay open after the shell exits, so the completion event never arrived even though the command was done; these now settle with the real exit code and a note that background output is no longer captured
|
||||
- Typing an `@` mention from your home directory no longer indexes your entire home folder. That could take memory into the gigabytes and get the process killed; the home directory and filesystem root are now skipped entirely
|
||||
- Web search is now enabled by default outside YOLO mode, and tool settings fail closed if they cannot be loaded
|
||||
- Claude Code no longer asks for an API key it never reads. It authenticates from the local `claude` CLI's own credential store, but was reported as an API-key provider, so a keyless entry was refused and the workaround was to save a dummy key
|
||||
- Pasted credentials with invisible characters no longer persist corrupted. A BOM or zero-width character carried in from a copy-paste produced 401s indistinguishable from a wrong key; credential fields are now stripped of control and format characters on save
|
||||
- Starting a new task no longer flickers through the idle state. The Hub publishes the new session's record as "idle" while the start request is still in flight, so the composer placeholder and the request indicator switched to idle and back for a frame on every new task. A transient idle arriving during a submission is now held back; a real failure or abort still applies immediately
|
||||
- The model picker keeps section headers visible while you search. Cline Pass lists the same model in both the Subscribed and Free tiers, so flattening the sections during search produced two identical-looking rows
|
||||
- `apply_patch` "Add File" now refuses to overwrite an existing file instead of silently replacing it
|
||||
- Fixed session import paths resolving incorrectly on Windows
|
||||
- The desktop backend now starts off the command path, so startup no longer blocks the UI
|
||||
- The SDK can now connect to authenticated remote Hubs
|
||||
|
||||
## 0.0.23
|
||||
|
||||
- Agent Plugins are now discovered and run by the shared Hub. Packages under `~/.agents/plugins` are validated from their `plugin.json`, their valid Agent Skills become available to the agent, and their stdio / Streamable HTTP / SSE MCP servers start automatically. Settings → Customize lists Agent Plugins separately from Cline Plugins, with each plugin's description, badge, and contributed tools, and enable/disable is Hub-managed per plugin. Workspace `.agents/plugins` directories are intentionally ignored
|
||||
- The "Cline Hub was updated" dialog no longer appears on every launch and reconnect. The app no longer prompts about a Hub running the same core version it does — a desktop and CLI release cut from different commits bundle the same core but never share a build fingerprint, so anyone with both installed got a dialog whose "Update and restart" looped on "no app update available". The build-mismatch dialog now also waits until an app update is actually staged, and "Later" sticks across session switches, reloads, and relaunches instead of resurfacing every time. A Hub the app genuinely cannot talk to still warns every time
|
||||
- Signing in now shows the device confirmation code in the app while you wait on the browser, so you can match it against the code the browser asks you to confirm — in onboarding, Account settings, and the provider list
|
||||
- Voice input failures caused by provider setup — missing credentials, transcription config — now take you straight to voice settings instead of a toast you cannot act on. Genuine microphone permission failures still toast, with a clearer message
|
||||
- Fixed the scheduled-task report vanishing when a finished run's step collapsed
|
||||
- Fixed one wedged MCP server blocking the rest from shutting down, leaking their processes
|
||||
|
||||
## 0.0.22
|
||||
|
||||
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.22",
|
||||
"version": "0.0.24",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -16,6 +16,7 @@ sidecar/
|
||||
├── index.ts # Entry point: starts HTTP+WS server
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── client-context.ts # Desktop client/account identity for shared telemetry
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
@@ -81,6 +82,15 @@ The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
|
||||
the desktop start the same detached Hub when no CLI process has started it yet.
|
||||
Startup discovery and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
Every create, restart, fork, and restore also attaches the serializable Desktop
|
||||
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
|
||||
that context across the Hub transport and scopes the daemon-owned telemetry
|
||||
service to the originating surface. This keeps lifecycle events centralized in
|
||||
Core while reporting Desktop dimensions (`cline_type: "desktop"`, `platform:
|
||||
"Cline Desktop"`, and the Desktop app version) and the current account and
|
||||
organization. The shared Hub telemetry singleton is never mutated per session,
|
||||
so concurrent CLI and Desktop tasks retain their own attribution.
|
||||
|
||||
### 2. Tool Approval — Client-Owned Promise Resolution
|
||||
|
||||
The shared Hub routes approval requests back to the client that created the
|
||||
|
||||
@@ -13,6 +13,8 @@ import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
createDesktopMistakeLimitPrompt,
|
||||
createDesktopMistakeRecovery,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
@@ -22,7 +24,11 @@ import {
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import { handleCoreSessionEvent } from "./context";
|
||||
import {
|
||||
handleCoreSessionEvent,
|
||||
requestSidecarAskQuestion,
|
||||
resolveSidecarAskQuestion,
|
||||
} from "./context";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("resolveDesktopSessionMode", () => {
|
||||
@@ -195,25 +201,49 @@ describe("hasProviderChanged", () => {
|
||||
|
||||
describe("pathless session starts", () => {
|
||||
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
expect(input.config).not.toHaveProperty("enableSpawnAgent");
|
||||
expect(input.config).not.toHaveProperty("enableAgentTeams");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
});
|
||||
const start = vi.fn(
|
||||
async (input: {
|
||||
config: Record<string, unknown>;
|
||||
localRuntime?: {
|
||||
extensionContext?: {
|
||||
client?: Record<string, unknown>;
|
||||
user?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
expect(input.config).not.toHaveProperty("enableSpawnAgent");
|
||||
expect(input.config).not.toHaveProperty("enableAgentTeams");
|
||||
expect(input.localRuntime?.extensionContext?.client).toMatchObject({
|
||||
name: "cline-desktop",
|
||||
platform: "Cline Desktop",
|
||||
});
|
||||
expect(input.localRuntime?.extensionContext?.user).toEqual({
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
cwd: "/home/host/.cline/data/workspaces/chat",
|
||||
workspace_root: "/home/host/.cline/data/workspaces/chat",
|
||||
},
|
||||
manifestPath: "/tmp/session-pathless.json",
|
||||
messagesPath: "/tmp/session-pathless.messages.json",
|
||||
};
|
||||
},
|
||||
);
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
telemetryUser: {
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
@@ -545,7 +575,7 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full-history fork on the current workspace without restoring", async () => {
|
||||
it("keeps a full-history fork on the current workspace and cancels source questions", async () => {
|
||||
const sourceSessionId = `source-full-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "first prompt" },
|
||||
@@ -588,8 +618,19 @@ describe("session forks", () => {
|
||||
},
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingQuestions: new Map(),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const pendingDecision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => sourceSessionId,
|
||||
)({
|
||||
iteration: 5,
|
||||
consecutiveMistakes: 6,
|
||||
maxConsecutiveMistakes: 6,
|
||||
reason: "tool_execution_failed",
|
||||
});
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "fork",
|
||||
sessionId: sourceSessionId,
|
||||
@@ -599,6 +640,8 @@ describe("session forks", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pendingDecision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialMessages: sourceMessages }),
|
||||
@@ -1753,3 +1796,487 @@ Follow the desktop send workflow instructions.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mistake-limit prompt", () => {
|
||||
function createPromptContext() {
|
||||
const send = vi.fn();
|
||||
const steer = vi.fn(async () => undefined);
|
||||
const ctx = {
|
||||
wsClients: new Set([{ send }]),
|
||||
streamIndices: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
liveSessions: new Map(),
|
||||
sessionManager: {
|
||||
send: steer,
|
||||
stop: vi.fn(async () => {}),
|
||||
abort: vi.fn(async () => {}),
|
||||
},
|
||||
} as unknown as SidecarContext;
|
||||
const readQuestionRequest = () => {
|
||||
const raw = send.mock.calls
|
||||
.map(
|
||||
([encoded]) =>
|
||||
JSON.parse(String(encoded)) as {
|
||||
event: { name: string; payload: Record<string, unknown> };
|
||||
},
|
||||
)
|
||||
.find((message) => message.event.name === "ask_question_requested");
|
||||
return raw?.event.payload as
|
||||
| {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
return { ctx, steer, readQuestionRequest };
|
||||
}
|
||||
const limitContext = {
|
||||
iteration: 15,
|
||||
consecutiveMistakes: 6,
|
||||
maxConsecutiveMistakes: 6,
|
||||
reason: "tool_execution_failed" as const,
|
||||
details:
|
||||
"Detected 5 consecutive identical calls to `editor`; stopping to avoid a loop.",
|
||||
};
|
||||
|
||||
it("holds tool and model hooks until Continue has queued recovery guidance", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
let finishSteering!: () => void;
|
||||
steer.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<undefined>((resolve) => {
|
||||
finishSteering = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
|
||||
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
|
||||
expect(recovery.onConsecutiveMistakeLimitReached(limitContext)).toBe(
|
||||
decision,
|
||||
);
|
||||
let released = false;
|
||||
const waiting = Promise.all([
|
||||
recovery.hooks.beforeModel(),
|
||||
recovery.hooks.beforeTool(),
|
||||
recovery.hooks.afterTool(),
|
||||
]).then((results) => {
|
||||
released = true;
|
||||
return results;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(released).toBe(false);
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(steer).toHaveBeenCalledTimes(1);
|
||||
expect(released).toBe(false);
|
||||
finishSteering();
|
||||
await expect(decision).resolves.toMatchObject({ action: "continue" });
|
||||
await expect(waiting).resolves.toEqual([undefined, undefined, undefined]);
|
||||
expect(released).toBe(true);
|
||||
await expect(recovery.hooks.beforeModel()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves ordinary questions alone when cancelling a mistake prompt", async () => {
|
||||
const { ctx, readQuestionRequest } = createPromptContext();
|
||||
const decision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-1",
|
||||
)(limitContext);
|
||||
const mistakeRequestId = readQuestionRequest()?.requestId;
|
||||
const normalQuestion = requestSidecarAskQuestion(
|
||||
ctx,
|
||||
"Which file?",
|
||||
["a", "b"],
|
||||
{ sessionId: "session-1", agentId: "desktop", iteration: 1 },
|
||||
);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
const remaining = [...ctx.pendingQuestions.values()][0];
|
||||
expect(remaining.item.requestId).not.toBe(mistakeRequestId);
|
||||
resolveSidecarAskQuestion(ctx, remaining.item.requestId, "a");
|
||||
await expect(normalQuestion).resolves.toBe("a");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"answer",
|
||||
"abort",
|
||||
] as const)("releases waiting hooks with Stop on %s", async (action) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
|
||||
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
|
||||
const waiting = Promise.all([
|
||||
recovery.hooks.beforeModel(),
|
||||
recovery.hooks.beforeTool(),
|
||||
recovery.hooks.afterTool(),
|
||||
]);
|
||||
if (action === "answer") {
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"Stop this run",
|
||||
);
|
||||
} else {
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
}
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
for (const control of await waiting)
|
||||
expect(control).toMatchObject({ stop: true });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the active session's user instead of stopping silently", async () => {
|
||||
const { ctx, readQuestionRequest } = createPromptContext();
|
||||
// Session ids are only known after start() resolves; the prompt must
|
||||
// read the id at prompt time, not at construction time.
|
||||
let sessionId = "";
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => sessionId);
|
||||
sessionId = "session-late";
|
||||
|
||||
const decision = decide(limitContext);
|
||||
const request = readQuestionRequest();
|
||||
expect(request).toMatchObject({
|
||||
sessionId: "session-late",
|
||||
options: ["Try a different approach", "Stop this run"],
|
||||
});
|
||||
expect(request?.question).toContain("repeated mistakes or tool calls");
|
||||
expect(request?.question).toContain("identical calls to `editor`");
|
||||
|
||||
expect(
|
||||
resolveSidecarAskQuestion(ctx, request?.requestId ?? "", "Stop this run"),
|
||||
).toBe(true);
|
||||
await expect(decision).resolves.toEqual({
|
||||
action: "stop",
|
||||
reason: "stopped after mistake_limit_reached prompt",
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers recovery guidance only through steering", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
|
||||
const decision = decide(limitContext);
|
||||
const request = readQuestionRequest();
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
request?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
);
|
||||
|
||||
const result = await decision;
|
||||
expect(result).toEqual({ action: "continue" });
|
||||
expect(steer).toHaveBeenCalledExactlyOnceWith({
|
||||
sessionId: "session-1",
|
||||
prompt: expect.stringContaining("Do not repeat the same call"),
|
||||
delivery: "steer",
|
||||
});
|
||||
expect(steer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining("identical calls to `editor`"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stop",
|
||||
" STOP THIS RUN ",
|
||||
"2",
|
||||
"no",
|
||||
])("treats the free-text answer %s as Stop, like the CLI", async (answer) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-1",
|
||||
)(limitContext);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
answer,
|
||||
);
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rejected",
|
||||
"unavailable",
|
||||
])("stops waiting hooks when steering is %s", async (failure) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
if (failure === "rejected")
|
||||
steer.mockRejectedValueOnce(new Error("Disconnected"));
|
||||
else ctx.sessionManager = null;
|
||||
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
|
||||
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
|
||||
const waiting = Promise.all([
|
||||
recovery.hooks.beforeModel(),
|
||||
recovery.hooks.beforeTool(),
|
||||
recovery.hooks.afterTool(),
|
||||
]);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
);
|
||||
await expect(decision).resolves.toMatchObject({
|
||||
action: "stop",
|
||||
reason: expect.stringContaining("Could not send recovery guidance"),
|
||||
});
|
||||
for (const result of await waiting)
|
||||
expect(result).toMatchObject({ stop: true });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("passes free-text answers through as user guidance", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const decision = decide(limitContext);
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
readQuestionRequest()?.requestId ?? "",
|
||||
"read the file first, then edit",
|
||||
);
|
||||
await expect(decision).resolves.toEqual({ action: "continue" });
|
||||
expect(steer).toHaveBeenCalledExactlyOnceWith({
|
||||
sessionId: "session-1",
|
||||
prompt: expect.stringContaining(
|
||||
"User guidance: read the file first, then edit",
|
||||
),
|
||||
delivery: "steer",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses Continue for already-started iterations and asks again for new mistakes", async () => {
|
||||
const { ctx, steer } = createPromptContext();
|
||||
ctx.liveSessions.set("session-1", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: 0,
|
||||
status: "running",
|
||||
});
|
||||
const startIteration = (iteration: number) =>
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: { type: "iteration_start", iteration },
|
||||
},
|
||||
});
|
||||
const answer = (value: string) => {
|
||||
const pending = [...ctx.pendingQuestions.values()][0];
|
||||
expect(pending).toBeDefined();
|
||||
resolveSidecarAskQuestion(ctx, pending.item.requestId, value);
|
||||
};
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
startIteration(15);
|
||||
const first = decide(limitContext);
|
||||
// The model can advance while the client decision is pending.
|
||||
startIteration(20);
|
||||
// Do not extend the covered iterations while waiting for the hub's
|
||||
// steering acknowledgement: a newer step may already have the guidance.
|
||||
steer.mockImplementationOnce(async () => {
|
||||
startIteration(21);
|
||||
return undefined;
|
||||
});
|
||||
answer("Try a different approach");
|
||||
await expect(first).resolves.toMatchObject({ action: "continue" });
|
||||
|
||||
// A batch can have many failures in one iteration, followed by more
|
||||
// failures queued before the user answered. None needs another prompt.
|
||||
for (const iteration of [
|
||||
...Array<number>(20).fill(15),
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
]) {
|
||||
await expect(decide({ ...limitContext, iteration })).resolves.toEqual({
|
||||
action: "continue",
|
||||
});
|
||||
}
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).toHaveBeenCalledTimes(1);
|
||||
|
||||
startIteration(21);
|
||||
const next = decide({ ...limitContext, iteration: 21 });
|
||||
answer("Try a different approach");
|
||||
await expect(next).resolves.toMatchObject({ action: "continue" });
|
||||
expect(steer).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A new user run must not inherit the previous run's decision, even
|
||||
// though its iteration numbers start over.
|
||||
startIteration(1);
|
||||
startIteration(5);
|
||||
const newRun = decide({ ...limitContext, iteration: 5 });
|
||||
answer("Stop this run");
|
||||
await expect(newRun).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to stopping when no session owns the question", async () => {
|
||||
const { ctx, steer } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "");
|
||||
await expect(decide(limitContext)).resolves.toEqual({
|
||||
action: "stop",
|
||||
reason: `mistake_limit_reached: ${limitContext.details}`,
|
||||
});
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes an aborted run's question and rejects late answers", async () => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const decision = decide(limitContext);
|
||||
const request = readQuestionRequest();
|
||||
expect(ctx.pendingQuestions.size).toBe(1);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(
|
||||
resolveSidecarAskQuestion(
|
||||
ctx,
|
||||
request?.requestId ?? "",
|
||||
"Try a different approach",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stop",
|
||||
"abort",
|
||||
"reset",
|
||||
] as const)("cancels only the owning session's questions on %s", async (action) => {
|
||||
const { ctx } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const other = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-2",
|
||||
)(limitContext);
|
||||
const decision = decide(limitContext);
|
||||
await handleChatSessionCommand(ctx, { action, sessionId: "session-1" });
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(
|
||||
[...ctx.pendingQuestions.values()].map((p) => p.item.sessionId),
|
||||
).toEqual(["session-2"]);
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "abort",
|
||||
sessionId: "session-2",
|
||||
});
|
||||
await other;
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("times out an unanswered question and removes it from polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { ctx, steer } = createPromptContext();
|
||||
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
|
||||
const decision = decide(limitContext);
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"run",
|
||||
"session",
|
||||
])("cancels the question when the %s ends externally", async (kind) => {
|
||||
const { ctx, steer, readQuestionRequest } = createPromptContext();
|
||||
const decision = createDesktopMistakeLimitPrompt(
|
||||
ctx,
|
||||
() => "session-1",
|
||||
)(limitContext);
|
||||
const requestId = readQuestionRequest()?.requestId ?? "";
|
||||
if (kind === "session")
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "ended",
|
||||
payload: { sessionId: "session-1", reason: "stopped", ts: Date.now() },
|
||||
});
|
||||
else
|
||||
handleCoreSessionEvent(ctx, {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: {
|
||||
type: "done",
|
||||
reason: "aborted",
|
||||
text: "",
|
||||
iterations: 5,
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(decision).resolves.toMatchObject({ action: "stop" });
|
||||
expect(ctx.pendingQuestions.size).toBe(0);
|
||||
expect(
|
||||
resolveSidecarAskQuestion(ctx, requestId, "Try a different approach"),
|
||||
).toBe(false);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is wired into freshly started sessions as a local runtime option", async () => {
|
||||
const start = vi.fn(
|
||||
async (input: {
|
||||
config: Record<string, unknown>;
|
||||
localRuntime?: Record<string, unknown>;
|
||||
}) => {
|
||||
expect(input.config).not.toHaveProperty(
|
||||
"onConsecutiveMistakeLimitReached",
|
||||
);
|
||||
expect(
|
||||
typeof input.localRuntime?.onConsecutiveMistakeLimitReached,
|
||||
).toBe("function");
|
||||
expect(input.config).not.toHaveProperty("hooks");
|
||||
expect(input.localRuntime?.hooks).toMatchObject({
|
||||
beforeModel: expect.any(Function),
|
||||
beforeTool: expect.any(Function),
|
||||
afterTool: expect.any(Function),
|
||||
});
|
||||
return {
|
||||
sessionId: "session-limit",
|
||||
manifest: { cwd: "/tmp/ws", workspace_root: "/tmp/ws" },
|
||||
manifestPath: "/tmp/session-limit.json",
|
||||
messagesPath: "/tmp/session-limit.messages.json",
|
||||
};
|
||||
},
|
||||
);
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
} as unknown as SidecarContext;
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/ws",
|
||||
},
|
||||
});
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,14 +22,26 @@ import {
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
|
||||
import {
|
||||
buildClineSystemPrompt,
|
||||
type ConsecutiveMistakeLimitContext,
|
||||
type ConsecutiveMistakeLimitDecision,
|
||||
formatUserCommandBlock,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { createDesktopExtensionContext } from "./client-context";
|
||||
import {
|
||||
cancelSidecarMistakeQuestions,
|
||||
emitChunk,
|
||||
nowMs,
|
||||
requestSidecarAskQuestion,
|
||||
sendEvent,
|
||||
} from "./context";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
import type {
|
||||
@@ -401,7 +413,177 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
type MistakeLimitDecider = (
|
||||
context: ConsecutiveMistakeLimitContext,
|
||||
) => Promise<ConsecutiveMistakeLimitDecision>;
|
||||
|
||||
const MISTAKE_LIMIT_CONTINUE_OPTION = "Try a different approach";
|
||||
const MISTAKE_LIMIT_STOP_OPTION = "Stop this run";
|
||||
const MISTAKE_LIMIT_DETAIL_MAX_CHARS = 600;
|
||||
|
||||
/**
|
||||
* Desktop counterpart of the CLI's mistake-limit prompt
|
||||
* (apps/cli/src/runtime/interactive/mistakes.ts).
|
||||
*
|
||||
* When the core's loop detector or mistake tracker trips, it asks the client
|
||||
* how to proceed. Without a decision callback the default is "stop", which
|
||||
* reaches the webview as a plain aborted turn: indistinguishable from the
|
||||
* user pressing Stop, with no explanation. A model stuck re-issuing the same
|
||||
* failing tool call therefore looked like Cline randomly gave up mid-task.
|
||||
* Route the decision through the existing ask-question channel instead so
|
||||
* the user sees what went wrong and can choose.
|
||||
*
|
||||
* `getSessionId` is read at prompt time: for fresh starts the session id is
|
||||
* only known after `manager.start()` resolves, and the webview matches the
|
||||
* prompt to its active session by id.
|
||||
*/
|
||||
export function createDesktopMistakeLimitPrompt(
|
||||
ctx: SidecarContext,
|
||||
getSessionId: () => string,
|
||||
): MistakeLimitDecider {
|
||||
return async (context) => {
|
||||
const sessionId = getSessionId().trim();
|
||||
const recovery = ctx.liveSessions.get(sessionId)?.mistakeRecovery;
|
||||
if (
|
||||
recovery?.continuedThroughIteration !== undefined &&
|
||||
context.iteration <= recovery.continuedThroughIteration
|
||||
) {
|
||||
// The tracker serializes decisions, so old failures can arrive after
|
||||
// Continue. The user has already answered for these in-flight steps.
|
||||
return { action: "continue" };
|
||||
}
|
||||
const detail = context.details?.trim() ?? "";
|
||||
const truncatedDetail =
|
||||
detail.length > MISTAKE_LIMIT_DETAIL_MAX_CHARS
|
||||
? `${detail.slice(0, MISTAKE_LIMIT_DETAIL_MAX_CHARS)}…`
|
||||
: detail;
|
||||
const question = [
|
||||
"Cline detected repeated mistakes or tool calls and needs your guidance.",
|
||||
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
|
||||
"How should Cline continue?",
|
||||
]
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n");
|
||||
|
||||
let answer: string;
|
||||
try {
|
||||
answer = await requestSidecarAskQuestion(
|
||||
ctx,
|
||||
question,
|
||||
[MISTAKE_LIMIT_CONTINUE_OPTION, MISTAKE_LIMIT_STOP_OPTION],
|
||||
{
|
||||
sessionId,
|
||||
agentId: "desktop-mistake-limit",
|
||||
iteration: context.iteration,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Prompt timed out or the session was torn down: fall back to the
|
||||
// core's default decision, but keep the reason so the stop is
|
||||
// attributable.
|
||||
ctx.logger?.log("Mistake-limit prompt unanswered; stopping run", {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `mistake_limit_reached: ${detail || context.reason}`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
if (["2", "stop this run", "stop", "n", "no"].includes(normalized)) {
|
||||
return {
|
||||
action: "stop",
|
||||
reason: "stopped after mistake_limit_reached prompt",
|
||||
};
|
||||
}
|
||||
const customGuidance =
|
||||
normalized.length > 0 &&
|
||||
normalized !== "1" &&
|
||||
normalized !== MISTAKE_LIMIT_CONTINUE_OPTION.toLowerCase()
|
||||
? answer.trim()
|
||||
: "";
|
||||
const guidance = [
|
||||
"The run reached the limit for repeated mistakes or tool calls.",
|
||||
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
|
||||
"Do not repeat the same call. Re-check the tool's parameter requirements, fix the call, and try a different approach.",
|
||||
customGuidance ? `User guidance: ${customGuidance}` : "",
|
||||
]
|
||||
.filter((line) => line.length > 0)
|
||||
.join(" ");
|
||||
// Use the existing steering queue so the running model receives the
|
||||
// guidance, including any instructions entered in the desktop prompt.
|
||||
const manager = ctx.sessionManager;
|
||||
try {
|
||||
if (!manager) throw new Error("Desktop session manager is unavailable");
|
||||
const continuedThroughIteration = Math.max(
|
||||
context.iteration,
|
||||
recovery?.latestIteration ?? context.iteration,
|
||||
);
|
||||
await manager.send({ sessionId, prompt: guidance, delivery: "steer" });
|
||||
if (recovery) {
|
||||
recovery.continuedThroughIteration = continuedThroughIteration;
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
ctx.logger?.log("Failed to steer mistake-limit guidance", {
|
||||
sessionId,
|
||||
error: detail,
|
||||
});
|
||||
// Releasing the hooks without the guidance would resume the same
|
||||
// failing loop. Only Continue after the steering request succeeds.
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `Could not send recovery guidance: ${detail}`,
|
||||
};
|
||||
}
|
||||
// Steering already delivers the guidance; do not also append it via
|
||||
// the mistake tracker's recovery-notice path.
|
||||
return { action: "continue" };
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopMistakeRecovery(
|
||||
ctx: SidecarContext,
|
||||
getSessionId: () => string,
|
||||
) {
|
||||
const prompt = createDesktopMistakeLimitPrompt(ctx, getSessionId);
|
||||
let pendingDecision: Promise<ConsecutiveMistakeLimitDecision> | undefined;
|
||||
const waitForDecision = async () => {
|
||||
const decision = await pendingDecision;
|
||||
return decision?.action === "stop"
|
||||
? { stop: true, reason: decision.reason }
|
||||
: undefined;
|
||||
};
|
||||
return {
|
||||
onConsecutiveMistakeLimitReached: (
|
||||
context: ConsecutiveMistakeLimitContext,
|
||||
) => {
|
||||
if (!pendingDecision) {
|
||||
pendingDecision = prompt(context).finally(() => {
|
||||
pendingDecision = undefined;
|
||||
});
|
||||
}
|
||||
return pendingDecision;
|
||||
},
|
||||
hooks: {
|
||||
// The decision callback alone does not pause the SDK. These existing
|
||||
// awaited hooks hold desktop runs at tool/model boundaries until the
|
||||
// user answers. afterTool holds before the next iteration consumes
|
||||
// the recovery guidance queued by the prompt's Continue action.
|
||||
beforeModel: waitForDecision,
|
||||
beforeTool: waitForDecision,
|
||||
afterTool: waitForDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(
|
||||
config: JsonRecord,
|
||||
telemetryUser?: SidecarContext["telemetryUser"],
|
||||
mistakeRecovery?: ReturnType<typeof createDesktopMistakeRecovery>,
|
||||
): JsonRecord {
|
||||
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
|
||||
const workspaceRoot =
|
||||
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
|
||||
@@ -444,6 +626,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
checkpoint: { enabled: true },
|
||||
sessions: config.sessions,
|
||||
initialMessages: config.initialMessages,
|
||||
extensionContext: createDesktopExtensionContext(telemetryUser),
|
||||
...mistakeRecovery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -674,8 +858,14 @@ async function handleStart(
|
||||
: requestedSessionId
|
||||
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
|
||||
: undefined;
|
||||
// Resolved once start() returns; the mistake-limit prompt reads it lazily.
|
||||
let startedSessionId = requestedSessionId;
|
||||
const coreConfig: JsonRecord = {
|
||||
...buildCoreSessionConfig(request.config),
|
||||
...buildCoreSessionConfig(
|
||||
request.config,
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => startedSessionId),
|
||||
),
|
||||
systemPrompt,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
};
|
||||
@@ -697,6 +887,7 @@ async function handleStart(
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
startedSessionId = sessionId;
|
||||
const workspaceRoot = startResult.manifest.workspace_root;
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
@@ -794,6 +985,7 @@ async function handleAttach(
|
||||
|
||||
async function startRebuiltSession(
|
||||
manager: ClineCore,
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
@@ -805,11 +997,15 @@ async function startRebuiltSession(
|
||||
: undefined;
|
||||
const restarted = await manager.start({
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => sessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -854,11 +1050,13 @@ async function rebuildSessionForProviderChange(
|
||||
resolveSystemPrompt(nextConfig),
|
||||
]);
|
||||
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session provider changed");
|
||||
await manager.stop(sessionId);
|
||||
let replacementStarted = false;
|
||||
try {
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
ctx,
|
||||
sessionId,
|
||||
nextConfig,
|
||||
nextSystemPrompt,
|
||||
@@ -880,6 +1078,7 @@ async function rebuildSessionForProviderChange(
|
||||
}
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
ctx,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
previousSystemPrompt,
|
||||
@@ -996,9 +1195,9 @@ async function handleSend(
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (session?.attachedViaHub) {
|
||||
// Once ClineCore sends a turn, its HubRuntimeHost owns the session
|
||||
// subscription. Stop projecting the observer stream as well or every
|
||||
// assistant/tool update (including command chunks) is emitted twice.
|
||||
// Once ClineCore sends a turn it owns the session: the attach-time
|
||||
// connection refresh above has happened and the observer projection is
|
||||
// muted by its subscription, so the session is no longer attach-only.
|
||||
session.attachedViaHub = false;
|
||||
}
|
||||
if (delivery === "queue") {
|
||||
@@ -1133,6 +1332,7 @@ async function handleStop(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session stopped");
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -1148,6 +1348,7 @@ async function handleAbort(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Run aborted");
|
||||
await getSessionManager(ctx).abort(sessionId, "user_abort");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -1289,12 +1490,18 @@ async function handleForkUnlocked(
|
||||
},
|
||||
};
|
||||
const systemPrompt = await resolveSystemPrompt(forkConfig);
|
||||
// Assigned below once the forked session exists; read lazily by the prompt.
|
||||
let newSessionId = "";
|
||||
const startInput = {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => newSessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1311,7 +1518,6 @@ async function handleForkUnlocked(
|
||||
readSessionCheckpointHistory({ metadata: sourceMetadata }),
|
||||
forkBeforeRunCount,
|
||||
) !== undefined;
|
||||
let newSessionId: string;
|
||||
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
|
||||
const cwd =
|
||||
restoreWorkspacePath ||
|
||||
@@ -1354,6 +1560,11 @@ async function handleForkUnlocked(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
cancelSidecarMistakeQuestions(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
"Session replaced by fork",
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
@@ -1378,6 +1589,7 @@ async function handleReset(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session reset");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (
|
||||
session?.busy ||
|
||||
@@ -1416,6 +1628,8 @@ async function handleRestoreCheckpoint(
|
||||
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
|
||||
const manager = getSessionManager(ctx);
|
||||
return withWorkspaceRestoreLock(ctx, cwd, async () => {
|
||||
// Updated once restore() returns; read lazily by the mistake-limit prompt.
|
||||
let restoredSessionId = sourceSessionId;
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount: runCount,
|
||||
@@ -1423,10 +1637,14 @@ async function handleRestoreCheckpoint(
|
||||
restore: { messages: true, workspace: true },
|
||||
start: {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => restoredSessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1438,10 +1656,16 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
restoredSessionId = sessionId;
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
cancelSidecarMistakeQuestions(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
"Session checkpoint restored",
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as os from "node:os";
|
||||
import { resolveCoreDistinctId } from "@cline/core";
|
||||
import type {
|
||||
ClientContext,
|
||||
ExtensionContext,
|
||||
TelemetryMetadata,
|
||||
UserContext,
|
||||
} from "@cline/shared";
|
||||
import { version } from "../package.json";
|
||||
|
||||
/** Shared identity for request headers, Hub attribution, and telemetry. */
|
||||
export const DESKTOP_CLIENT_CONTEXT = {
|
||||
name: "cline-desktop",
|
||||
version,
|
||||
platform: "Cline Desktop",
|
||||
platformVersion: version,
|
||||
isMultiRoot: false,
|
||||
} as const satisfies ClientContext;
|
||||
|
||||
export const DESKTOP_TELEMETRY_METADATA = {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: DESKTOP_CLIENT_CONTEXT.platform,
|
||||
platform_version: DESKTOP_CLIENT_CONTEXT.platformVersion,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
} satisfies TelemetryMetadata;
|
||||
|
||||
export function resolveDesktopTelemetryUser(input?: {
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
organizationId?: string;
|
||||
}): UserContext {
|
||||
const accountId = input?.accountId?.trim();
|
||||
return accountId
|
||||
? {
|
||||
distinctId: accountId,
|
||||
accountId,
|
||||
email: input?.email,
|
||||
organizationId: input?.organizationId,
|
||||
}
|
||||
: {
|
||||
distinctId: resolveCoreDistinctId(),
|
||||
accountId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serializable context attached to every Desktop session sent to the Hub. */
|
||||
export function createDesktopExtensionContext(
|
||||
user?: UserContext,
|
||||
): ExtensionContext {
|
||||
return {
|
||||
client: DESKTOP_CLIENT_CONTEXT,
|
||||
...(user ? { user: { ...user } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -21,6 +22,7 @@ vi.mock("@cline/core", async () => {
|
||||
executeClineAccountAction: executeClineAccountActionMock,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
saveProviderSettings = persistProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
@@ -31,11 +33,13 @@ vi.mock("@cline/core", async () => {
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const setDistinctId = vi.fn();
|
||||
const updateCommonProperties = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
telemetry: { capture, setDistinctId, updateCommonProperties },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
return { ctx, capture, setDistinctId, updateCommonProperties };
|
||||
}
|
||||
|
||||
const FETCH_ME_ARGS = {
|
||||
@@ -53,12 +57,14 @@ beforeEach(() => {
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
persistProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("cline_account command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
it("returns a typed not-authenticated result and restores anonymous telemetry when signed out", async () => {
|
||||
const { ctx, capture, setDistinctId, updateCommonProperties } =
|
||||
createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
@@ -72,6 +78,14 @@ describe("cline_account command auth states", () => {
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(setDistinctId).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the account action unchanged when a fresh token resolves", async () => {
|
||||
@@ -171,7 +185,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
@@ -182,6 +196,62 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBe("acct-1");
|
||||
expect(setDistinctId).toHaveBeenCalledWith("acct-1");
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_id: "acct-1", account_id: "acct-1" }),
|
||||
);
|
||||
expect(ctx.telemetryUser).toEqual({
|
||||
distinctId: "acct-1",
|
||||
accountId: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies and persists the active organization for task telemetry", async () => {
|
||||
const { ctx, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: { accountId: "acct-1", accessToken: "token" },
|
||||
});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
id: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(updateCommonProperties).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: "acct-1",
|
||||
organization_id: "org-1",
|
||||
}),
|
||||
);
|
||||
expect(persistProviderSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
memberId: "member-1",
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
expect(ctx.telemetryUser).toEqual({
|
||||
distinctId: "acct-1",
|
||||
accountId: "acct-1",
|
||||
email: "dev@example.com",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the signed-in identity intact across an organization switch", async () => {
|
||||
@@ -219,7 +289,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -233,10 +303,28 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
await runOperation(ctx, "fetchMe");
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
expect(ctx.telemetryUser).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: null,
|
||||
distinctId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(setDistinctId).toHaveBeenLastCalledWith(
|
||||
ctx.telemetryUser?.distinctId,
|
||||
);
|
||||
expect(ctx.telemetryUser?.distinctId).not.toBe("acct-1");
|
||||
expect(updateCommonProperties).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
account_email: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the identity when sign-out blanks the cline auth settings", async () => {
|
||||
const { ctx } = createContext();
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -259,6 +347,22 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
expect(await currentFlagsUserId()).toBeUndefined();
|
||||
expect(ctx.telemetryUser).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: null,
|
||||
distinctId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(setDistinctId).toHaveBeenLastCalledWith(
|
||||
ctx.telemetryUser?.distinctId,
|
||||
);
|
||||
expect(updateCommonProperties).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
user_id: undefined,
|
||||
account_id: undefined,
|
||||
organization_id: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores settings writes for other providers", async () => {
|
||||
|
||||
@@ -15,7 +15,9 @@ import type {
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
captureAuthRefreshSoftFailure,
|
||||
clearAccountTelemetryIdentity,
|
||||
createConfiguredStreamingTranscriptionSession,
|
||||
createUserInstructionConfigService,
|
||||
ensureCustomProvidersLoaded,
|
||||
@@ -23,14 +25,17 @@ import {
|
||||
fetchClineRecommendedModels,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
identifyAccount,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
parseMcpServerRegistration,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
probeMcpServerConnection,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
resolveLocalClineAuthToken,
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
@@ -66,6 +71,7 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
|
||||
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import { resolveDesktopTelemetryUser } from "./client-context";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
@@ -310,14 +316,23 @@ function removePathIfExists(
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function syncFeatureFlagsAccountFromResult(
|
||||
function syncAccountContextFromResult(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
operation: string,
|
||||
result: unknown,
|
||||
): void {
|
||||
if (operation === "fetchMe") {
|
||||
const user = result as { id?: string; email?: string } | undefined;
|
||||
const user = result as ClineAccountUser | undefined;
|
||||
if (user?.id) {
|
||||
const identity = resolveClineAccountTelemetryIdentity(user);
|
||||
ctx.telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId: identity.id,
|
||||
email: identity.email,
|
||||
organizationId: identity.organizationId,
|
||||
});
|
||||
identifyAccount(ctx.telemetry, identity);
|
||||
persistClineAccountTelemetryIdentity(manager, identity);
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: user.id, email: user.email },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
@@ -327,12 +342,39 @@ function syncFeatureFlagsAccountFromResult(
|
||||
}
|
||||
}
|
||||
|
||||
function syncFeatureFlagsAccountFromSettings(
|
||||
function syncAccountContextFromSettings(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
): void {
|
||||
const auth = manager.getProviderSettings("cline")?.auth;
|
||||
const accountId = auth?.accountId?.trim();
|
||||
if (!auth || !accountId) {
|
||||
syncSignedOutAccountContext(ctx);
|
||||
return;
|
||||
}
|
||||
ctx.telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId,
|
||||
organizationId: auth.organizationId,
|
||||
});
|
||||
identifyAccount(ctx.telemetry, {
|
||||
id: accountId,
|
||||
provider: "cline",
|
||||
organizationId: auth.organizationId,
|
||||
organizationName: auth.organizationName,
|
||||
memberId: auth.memberId,
|
||||
});
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
|
||||
{ id: accountId },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
|
||||
function syncSignedOutAccountContext(ctx: SidecarContext): void {
|
||||
const telemetryUser = resolveDesktopTelemetryUser();
|
||||
ctx.telemetryUser = telemetryUser;
|
||||
clearAccountTelemetryIdentity(ctx.telemetry, telemetryUser.distinctId);
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
@@ -1831,10 +1873,7 @@ export async function handleCommand(
|
||||
// an expired or server-revoked token. Explicit sign-out is handled
|
||||
// at its source in `save_provider_settings`; this catches the rest
|
||||
// so a stale account never keeps serving its rollout cohort.
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
syncSignedOutAccountContext(ctx);
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
@@ -1847,7 +1886,7 @@ export async function handleCommand(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
syncFeatureFlagsAccountFromResult(ctx, operation, result);
|
||||
syncAccountContextFromResult(ctx, manager, operation, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2050,7 +2089,7 @@ export async function handleCommand(
|
||||
// authoritative signal — it fires the moment credentials are cleared
|
||||
// rather than waiting for the next account fetch.
|
||||
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
|
||||
syncFeatureFlagsAccountFromSettings(ctx, manager);
|
||||
syncAccountContextFromSettings(ctx, manager);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -1271,3 +1271,215 @@ describe("disposeSidecarContext attachment cleanup", () => {
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat chunk pipe selection", () => {
|
||||
async function createStreamingContext(
|
||||
sessionId: string,
|
||||
coreSubscriptions: Set<string> = new Set(),
|
||||
) {
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
ctx.liveSessions.set(sessionId, {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
ctx.sessionManager = {
|
||||
hasSessionSubscription: (id: string) => coreSubscriptions.has(id),
|
||||
} as never;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function coreTextEvent(sessionId: string, text: string) {
|
||||
return {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId,
|
||||
event: { type: "content_start", contentType: "text", text },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function eventsFor(ctx: SidecarContext, name: string) {
|
||||
return readEvents(ctx)
|
||||
.filter((message) => message.event.name === name)
|
||||
.map((message) => message.event.payload);
|
||||
}
|
||||
|
||||
function chunksFor(ctx: SidecarContext, stream: string): string[] {
|
||||
return eventsFor(ctx, "chat_event")
|
||||
.filter((payload) => (payload as { stream?: string }).stream === stream)
|
||||
.map((payload) => String((payload as { chunk?: string }).chunk));
|
||||
}
|
||||
|
||||
it("emits one copy when both pipes carry the same delta", async () => {
|
||||
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
// Opening a session arms both pipes: ClineCore subscribes to the session
|
||||
// and `attach` enables the observer projection, so the hub publishes each
|
||||
// delta to both sockets.
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "Pack " },
|
||||
});
|
||||
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "Pack "));
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "my box" },
|
||||
});
|
||||
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "my box"));
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["Pack ", "my box"]);
|
||||
});
|
||||
|
||||
it("still streams sessions only the observer delivers", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext("session-1");
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "remote " },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "run" },
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["remote ", "run"]);
|
||||
});
|
||||
|
||||
it("mutes the whole observer projection, not just text", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "tool.started",
|
||||
sessionId: "session-1",
|
||||
payload: { toolCallId: "call-1", toolName: "run_commands" },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "run.completed",
|
||||
sessionId: "session-1",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_tool_call_start")).toEqual([]);
|
||||
expect(eventsFor(ctx, "chat_session_ended")).toEqual([]);
|
||||
expect(ctx.liveSessions.get("session-1")?.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the subscription as it comes and goes", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const coreSubscriptions = new Set<string>();
|
||||
const ctx = await createStreamingContext("session-1", coreSubscriptions);
|
||||
const delta = (text: string) =>
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
delta("observer first");
|
||||
// A send (or pending-prompt list) subscribes ClineCore.
|
||||
coreSubscriptions.add("session-1");
|
||||
delta("muted");
|
||||
// `stop` drops the subscription; a run another client starts on the
|
||||
// same session is the observer's to render again.
|
||||
coreSubscriptions.delete("session-1");
|
||||
delta("observer again");
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual([
|
||||
"observer first",
|
||||
"observer again",
|
||||
]);
|
||||
});
|
||||
|
||||
it("decides per session", async () => {
|
||||
const { handleHubLiveEvent } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
ctx.liveSessions.set("session-2", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "one" },
|
||||
});
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-2",
|
||||
payload: { text: "two" },
|
||||
});
|
||||
|
||||
expect(chunksFor(ctx, "chat_text")).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("never drops chunks the sidecar produces itself", async () => {
|
||||
const { broadcastChunk } = await import("./context");
|
||||
const ctx = await createStreamingContext(
|
||||
"session-1",
|
||||
new Set(["session-1"]),
|
||||
);
|
||||
|
||||
broadcastChunk(ctx, "session-1", "chat_queued_prompt_start", "{}");
|
||||
|
||||
expect(chunksFor(ctx, "chat_queued_prompt_start")).toEqual(["{}"]);
|
||||
});
|
||||
|
||||
it("stamps chunks with a stable per-process boot id", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const first = await createStreamingContext("session-1");
|
||||
handleHubLiveEvent(first, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "a" },
|
||||
});
|
||||
handleHubLiveEvent(first, {
|
||||
event: "assistant.delta",
|
||||
sessionId: "session-1",
|
||||
payload: { text: "b" },
|
||||
});
|
||||
|
||||
const boots = readEvents(first)
|
||||
.filter((message) => message.event.name === "chat_event")
|
||||
.map((message) => (message.event.payload as { boot?: string }).boot);
|
||||
expect(boots).toHaveLength(2);
|
||||
expect(boots[0]).toBeTruthy();
|
||||
expect(boots[1]).toBe(boots[0]);
|
||||
|
||||
// A replacement sidecar restarts `index` at 1, so it must be
|
||||
// distinguishable by boot id.
|
||||
const second = createSidecarContext("/workspace/project");
|
||||
expect(second.bootId).not.toBe(first.bootId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,6 +187,7 @@ function emitChunk(
|
||||
chunk,
|
||||
ts,
|
||||
index: nextIndex,
|
||||
boot: ctx.bootId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,6 +345,7 @@ function handleAgentEvent(
|
||||
message: event.message,
|
||||
noticeType: event.noticeType,
|
||||
reason: event.reason,
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -367,6 +369,7 @@ function handleAgentEvent(
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Run ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -401,7 +404,19 @@ function handleAgentEvent(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "iteration_start":
|
||||
case "iteration_start": {
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
// Iterations restart at one for each user run. Keep the previous
|
||||
// answer only within the run in which it was supplied.
|
||||
if (event.iteration === 1 || !session.mistakeRecovery) {
|
||||
session.mistakeRecovery = { latestIteration: event.iteration };
|
||||
} else {
|
||||
session.mistakeRecovery.latestIteration = event.iteration;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "iteration_end":
|
||||
break;
|
||||
}
|
||||
@@ -520,6 +535,7 @@ export function handleCoreSessionEvent(
|
||||
}
|
||||
case "ended": {
|
||||
const { sessionId, reason } = event.payload;
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -570,12 +586,14 @@ export function createSidecarContext(
|
||||
observability: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
telemetryUser?: SidecarContext["telemetryUser"];
|
||||
} = {},
|
||||
): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
bootId: randomUUID(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
@@ -584,6 +602,7 @@ export function createSidecarContext(
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
telemetryUser: observability.telemetryUser,
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
@@ -724,6 +743,28 @@ export function resolveSidecarAskQuestion(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove prompts before their session is stopped or replaced in the UI. */
|
||||
export function cancelSidecarMistakeQuestions(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
): void {
|
||||
for (const pending of ctx.pendingQuestions?.values() ?? []) {
|
||||
if (
|
||||
pending.item.sessionId !== sessionId ||
|
||||
pending.item.context?.agentId !== "desktop-mistake-limit"
|
||||
)
|
||||
continue;
|
||||
ctx.pendingQuestions.delete(pending.item.requestId);
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error(reason));
|
||||
sendEvent(ctx, "ask_question_cancelled", {
|
||||
requestId: pending.item.requestId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createSidecarRuntimeCapabilities(
|
||||
ctx: SidecarContext,
|
||||
): RuntimeCapabilities {
|
||||
@@ -824,6 +865,15 @@ export function handleHubLiveEvent(
|
||||
if (!session?.attachedViaHub) {
|
||||
return;
|
||||
}
|
||||
// The observer client and ClineCore's own hub client are separate sockets
|
||||
// that both receive this session's events. This projection only exists for
|
||||
// sessions ClineCore is not subscribed to (it subscribes as a side effect
|
||||
// of start/send/pending_prompts and unsubscribes on stop); once it is,
|
||||
// `handleCoreSessionEvent` carries everything below and a second copy here
|
||||
// would double every delta, tool row, and status change.
|
||||
if (ctx.sessionManager?.hasSessionSubscription(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.event) {
|
||||
case "assistant.delta": {
|
||||
|
||||
@@ -14,6 +14,7 @@ function createContext(workspaceRoot: string): SidecarContext {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
bootId: "test-boot",
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { version } from "../package.json";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureExtensionActivated: vi.fn(),
|
||||
@@ -28,7 +29,14 @@ vi.mock("@cline/core", async () => {
|
||||
identifyAccount: mocks.identifyAccount,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings() {
|
||||
return { auth: { accountId: "account-1" } };
|
||||
return {
|
||||
auth: {
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
setSdkLogger: mocks.setSdkLogger,
|
||||
@@ -57,8 +65,10 @@ describe("desktop observability", () => {
|
||||
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform: "Cline Desktop",
|
||||
platform_version: version,
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
@@ -67,9 +77,18 @@ describe("desktop observability", () => {
|
||||
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
|
||||
id: "account-1",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
});
|
||||
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
|
||||
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
|
||||
expect(observability.telemetryUser).toEqual({
|
||||
distinctId: "account-1",
|
||||
accountId: "account-1",
|
||||
email: undefined,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await observability.dispose();
|
||||
await observability.dispose();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
captureExtensionActivated,
|
||||
createClineTelemetryServiceConfig,
|
||||
@@ -8,7 +7,11 @@ import {
|
||||
ProviderSettingsManager,
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import { version } from "../package.json";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
import {
|
||||
DESKTOP_TELEMETRY_METADATA,
|
||||
resolveDesktopTelemetryUser,
|
||||
} from "./client-context";
|
||||
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
export interface DesktopObservability {
|
||||
readonly logger: DesktopLoggerAdapter["core"];
|
||||
readonly telemetry: ITelemetryService;
|
||||
readonly telemetryUser?: UserContext;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,23 +32,23 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
|
||||
const telemetryHandle = createConfiguredTelemetryHandle({
|
||||
...createClineTelemetryServiceConfig({
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
},
|
||||
metadata: DESKTOP_TELEMETRY_METADATA,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const telemetry = telemetryHandle.telemetry;
|
||||
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
|
||||
const telemetryUser = resolveDesktopTelemetryUser({
|
||||
accountId: auth?.accountId,
|
||||
organizationId: auth?.organizationId,
|
||||
});
|
||||
if (auth?.accountId) {
|
||||
identifyAccount(telemetry, {
|
||||
id: auth.accountId,
|
||||
provider: "cline",
|
||||
organizationId: auth.organizationId,
|
||||
organizationName: auth.organizationName,
|
||||
memberId: auth.memberId,
|
||||
});
|
||||
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
return {
|
||||
logger,
|
||||
telemetry,
|
||||
telemetryUser,
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -60,6 +61,11 @@ export type LiveSession = {
|
||||
prompt?: string;
|
||||
title?: string;
|
||||
attachedViaHub?: boolean;
|
||||
/** Iterations already in flight when the user supplied recovery guidance. */
|
||||
mistakeRecovery?: {
|
||||
latestIteration: number;
|
||||
continuedThroughIteration?: number;
|
||||
};
|
||||
/** Materialized attachment files for prompts still waiting in the queue. */
|
||||
queuedAttachmentFiles?: Map<string, string[]>;
|
||||
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
|
||||
@@ -115,6 +121,12 @@ export type SidecarContext = {
|
||||
liveSessions: Map<string, LiveSession>;
|
||||
restoringWorkspacePaths: Set<string>;
|
||||
streamIndices: Map<string, number>;
|
||||
/**
|
||||
* Identifies this sidecar process. `streamIndices` restarts whenever the
|
||||
* sidecar does, so the webview needs to tell "index 1 of a new process"
|
||||
* apart from a replay of the run it already rendered.
|
||||
*/
|
||||
bootId: string;
|
||||
wsClients: Set<SidecarWebSocketClient>;
|
||||
pendingApprovals: Map<string, PendingToolApproval>;
|
||||
pendingQuestions: Map<string, PendingAskQuestion>;
|
||||
@@ -123,6 +135,8 @@ export type SidecarContext = {
|
||||
workspaceRoot: string;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
/** Analytics identity and explicit account state forwarded with each session. */
|
||||
telemetryUser?: UserContext;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
/**
|
||||
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-start-dragging",
|
||||
"notification:default"
|
||||
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -9,7 +9,8 @@ use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -283,21 +284,16 @@ async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
|
||||
struct DesktopBackendState {
|
||||
ws_endpoint: Mutex<Option<String>>,
|
||||
process: Mutex<Option<Child>>,
|
||||
shutting_down: Mutex<bool>,
|
||||
shutting_down: AtomicBool,
|
||||
}
|
||||
|
||||
impl DesktopBackendState {
|
||||
fn is_shutting_down(&self) -> bool {
|
||||
self.shutting_down
|
||||
.lock()
|
||||
.map(|guard| *guard)
|
||||
.unwrap_or(true)
|
||||
self.shutting_down.load(AtomicOrdering::Acquire)
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
if let Ok(mut guard) = self.shutting_down.lock() {
|
||||
*guard = true;
|
||||
}
|
||||
self.shutting_down.store(true, AtomicOrdering::Release);
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
@@ -511,10 +507,26 @@ fn ensure_desktop_backend_started_with(
|
||||
// callers (setup, the health-check loop, endpoint fetches from the
|
||||
// webview) serialize: the second caller blocks here, then sees the live
|
||||
// child and returns instead of spawning a duplicate.
|
||||
let mut process_guard = state
|
||||
let process_guard = state
|
||||
.process
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock desktop backend process state")?;
|
||||
ensure_desktop_backend_started_locked(state, process_guard, spawn_backend)
|
||||
}
|
||||
|
||||
/// The check-and-spawn that runs under the process lock. Split from the lock
|
||||
/// acquisition so a test can establish "shutdown began after the unlocked
|
||||
/// check but before the lock was taken" deterministically.
|
||||
fn ensure_desktop_backend_started_locked(
|
||||
state: &Arc<DesktopBackendState>,
|
||||
mut process_guard: MutexGuard<'_, Option<Child>>,
|
||||
spawn_backend: impl FnOnce() -> Result<Child, String>,
|
||||
) -> Result<(), String> {
|
||||
// stop() marks shutdown before taking this same process lock. Recheck
|
||||
// under the lock so a queued startup cannot spawn after shutdown.
|
||||
if state.is_shutting_down() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(existing) = process_guard.as_mut() {
|
||||
match existing.try_wait() {
|
||||
// A live child owns startup even while its endpoint is still
|
||||
@@ -667,17 +679,29 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_desktop_backend_endpoint(
|
||||
async fn get_desktop_backend_endpoint(
|
||||
backend_state: State<'_, Arc<DesktopBackendState>>,
|
||||
context: State<'_, AppContext>,
|
||||
) -> Result<String, String> {
|
||||
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
|
||||
let backend_state = backend_state.inner().clone();
|
||||
let context = context.inner().clone();
|
||||
let state_for_start = backend_state.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ensure_desktop_backend_started(&state_for_start, &context)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("desktop backend startup task failed: {error}"))??;
|
||||
|
||||
// Sidecar startup includes login-shell PATH resolution (bounded at 3s,
|
||||
// see sidecar/shell-path.ts) plus session-manager init, whose duration
|
||||
// varies by machine. Poll well past that combined worst case; the loop
|
||||
// returns as soon as the ready line arrives, so only failure waits long.
|
||||
// While pending this only waits — respawning is ensure's job, and it
|
||||
// refuses to start a second sidecar while the first one is still alive.
|
||||
// A child that dies mid-poll makes this return an error rather than
|
||||
// respawn: the next ensure call — the health-check loop within 5 seconds,
|
||||
// or this command when the webview reconnects — replaces the dead child.
|
||||
// Async sleeps keep Tauri's window event loop responsive while pending.
|
||||
for _ in 0..150 {
|
||||
if let Some(endpoint) = backend_state
|
||||
.ws_endpoint
|
||||
@@ -700,7 +724,7 @@ fn get_desktop_backend_endpoint(
|
||||
if child_exited {
|
||||
return Err("desktop backend exited before publishing its endpoint".to_string());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err("desktop backend endpoint not ready".to_string())
|
||||
}
|
||||
@@ -789,56 +813,82 @@ async fn check_for_update_now(
|
||||
}
|
||||
|
||||
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
|
||||
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
|
||||
/// resource at icons/dock/<id>.png.
|
||||
const APP_DOCK_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
|
||||
/// webview/lib/app-icon.ts. Every id has a matching bundled resource at
|
||||
/// icons/app/<id>.png.
|
||||
const APP_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
fn resolve_app_icon(app: &tauri::AppHandle, icon: &str) -> Result<PathBuf, String> {
|
||||
let icon_path = app
|
||||
.path()
|
||||
.resolve(
|
||||
format!("icons/app/{icon}.png"),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)
|
||||
.map_err(|e| format!("failed resolving app icon resource: {e}"))?;
|
||||
if !icon_path.exists() {
|
||||
return Err(format!(
|
||||
"app icon resource missing: {}",
|
||||
icon_path.display()
|
||||
));
|
||||
}
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
|
||||
async fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_ICONS.contains(&icon.as_str()) {
|
||||
return Err(format!("unknown app icon: {icon}"));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// "classic" also ships as a dock resource, so every choice loads the
|
||||
// same way; setApplicationIconImage's binding warns that passing nil
|
||||
// to restore the bundled icon may not be allowed.
|
||||
let icon_path = app
|
||||
.path()
|
||||
.resolve(
|
||||
format!("icons/dock/{icon}.png"),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)
|
||||
.map_err(|e| format!("failed resolving dock icon resource: {e}"))?;
|
||||
if !icon_path.exists() {
|
||||
return Err(format!(
|
||||
"dock icon resource missing: {}",
|
||||
icon_path.display()
|
||||
));
|
||||
}
|
||||
// Every choice uses a resource because AppKit does not support restoring
|
||||
// the bundled icon by passing a nil application icon.
|
||||
let icon_path = resolve_app_icon(&app, &icon)?;
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
use objc2::{AllocAnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSApplication, NSImage};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
let Some(image) = NSImage::initWithContentsOfFile(
|
||||
NSImage::alloc(),
|
||||
&NSString::from_str(&icon_path.to_string_lossy()),
|
||||
) else {
|
||||
eprintln!("[dock-icon] failed loading image: {}", icon_path.display());
|
||||
return;
|
||||
};
|
||||
// SAFETY: called on the main thread with a valid, non-nil image.
|
||||
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
|
||||
let result: Result<(), String> = (|| {
|
||||
let mtm = MainThreadMarker::new().ok_or_else(|| {
|
||||
"app icon update did not run on the main thread".to_string()
|
||||
})?;
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
let image = NSImage::initWithContentsOfFile(
|
||||
NSImage::alloc(),
|
||||
&NSString::from_str(&icon_path.to_string_lossy()),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
format!("failed loading app icon image: {}", icon_path.display())
|
||||
})?;
|
||||
// SAFETY: called on the main thread with a valid, non-nil image.
|
||||
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
|
||||
Ok(())
|
||||
})();
|
||||
let _ = result_tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("failed switching dock icon: {e}"))?;
|
||||
.map_err(|e| format!("failed switching app icon: {e}"))?;
|
||||
result_rx
|
||||
.await
|
||||
.map_err(|_| "app icon update ended before AppKit completed".to_string())??;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let icon_path = resolve_app_icon(&app, &icon)?;
|
||||
let image = tauri::image::Image::from_path(&icon_path)
|
||||
.map_err(|e| format!("failed loading app icon image: {e}"))?;
|
||||
let window = app
|
||||
.get_webview_window(MAIN_WINDOW_LABEL)
|
||||
.ok_or_else(|| "main window is unavailable".to_string())?;
|
||||
window
|
||||
.set_icon(image)
|
||||
.map_err(|e| format!("failed switching taskbar icon: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(false)
|
||||
@@ -1190,9 +1240,15 @@ fn main() {
|
||||
setup_tray_icon(app)?;
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
||||
eprintln!("[desktop-backend] startup failed: {error}");
|
||||
}
|
||||
let state_for_start = backend_state.clone();
|
||||
let context_for_start = app_context.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(error) =
|
||||
ensure_desktop_backend_started(&state_for_start, &context_for_start)
|
||||
{
|
||||
eprintln!("[desktop-backend] startup failed: {error}");
|
||||
}
|
||||
});
|
||||
// Dev builds are not installed app bundles, so there is nothing the
|
||||
// updater could meaningfully check or replace.
|
||||
if !cfg!(debug_assertions) {
|
||||
@@ -1443,6 +1499,28 @@ mod tests {
|
||||
state.stop();
|
||||
}
|
||||
|
||||
/// The interleaving where only the recheck under the lock stands between
|
||||
/// shutdown and a fresh spawn: startup has passed its unlocked shutdown
|
||||
/// check, stop() marks shutdown while startup is still waiting for the
|
||||
/// process lock, and then startup acquires the lock. Played out directly
|
||||
/// on one thread so the ordering is exact rather than scheduled.
|
||||
#[test]
|
||||
fn startup_queued_on_process_lock_does_not_spawn_after_shutdown() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
let spawn_count = AtomicUsize::new(0);
|
||||
|
||||
assert!(!state.is_shutting_down(), "the unlocked check passes");
|
||||
state.shutting_down.store(true, AtomicOrdering::Release);
|
||||
let process_guard = state.process.lock().expect("process lock should succeed");
|
||||
ensure_desktop_backend_started_locked(&state, process_guard, || {
|
||||
spawn_count.fetch_add(1, Ordering::SeqCst);
|
||||
spawn_pending_sidecar()
|
||||
})
|
||||
.expect("shutdown should make startup a no-op");
|
||||
|
||||
assert_eq!(spawn_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exited_child_is_replaced_on_next_startup_check() {
|
||||
let state = Arc::new(DesktopBackendState::default());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.22",
|
||||
"version": "0.0.24",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -38,7 +38,7 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"externalBin": ["bin/code-sidecar"],
|
||||
"resources": ["icons/dock/*.png"],
|
||||
"resources": ["icons/app/*.png"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Cline",
|
||||
"width": 1500,
|
||||
"height": 980,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"shadow": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,16 @@
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
:root {
|
||||
--window-title-bar-height: 3rem;
|
||||
}
|
||||
|
||||
@variant max-md {
|
||||
:root {
|
||||
--window-title-bar-height: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@@ -53,6 +63,17 @@
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* The Windows caption controls occupy the right edge of the shared title-bar row. */
|
||||
html[data-windows-custom-titlebar]
|
||||
[data-slot="window-title-bar-content-host"] {
|
||||
padding-right: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile toasts start below the caption row so both close buttons remain reachable. */
|
||||
html[data-windows-custom-titlebar] [data-slot="toast-viewport"] {
|
||||
@apply max-sm:top-(--window-title-bar-height);
|
||||
}
|
||||
|
||||
/* Chat Markdown polish and the streaming-title shimmer live in
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
type SessionHistoryItem,
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { readImportedFromTool } from "@/lib/session-import";
|
||||
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
@@ -217,8 +218,8 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// The dock reverts to the bundled icon every launch; re-apply the
|
||||
// user's choice once the shell is up.
|
||||
// The native app icon reverts to the bundled icon every launch; re-apply
|
||||
// the user's choice once the shell is up.
|
||||
void syncAppIcon();
|
||||
}, []);
|
||||
|
||||
@@ -601,6 +602,7 @@ function ChatThreadPane({
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
activityLabel,
|
||||
config,
|
||||
messages,
|
||||
error,
|
||||
@@ -1373,6 +1375,9 @@ function ChatThreadPane({
|
||||
: (sessionId ?? visibleHistorySession?.sessionId ?? null);
|
||||
const displayedMessages = hideDeletedSessionUi ? [] : messages;
|
||||
const displayedError = hideDeletedSessionUi ? null : error;
|
||||
const importedFromTool = readImportedFromTool(
|
||||
visibleHistorySession?.metadata,
|
||||
);
|
||||
const displayedStatus = hideDeletedSessionUi ? "idle" : status;
|
||||
const displayedSessionId = hideDeletedSessionUi ? null : sessionId;
|
||||
const displayedIsSwitching = hideDeletedSessionUi
|
||||
@@ -1594,7 +1599,9 @@ function ChatThreadPane({
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
activityLabel={activityLabel}
|
||||
error={displayedError}
|
||||
importedFromTool={importedFromTool}
|
||||
messages={displayedMessages}
|
||||
onEditMessage={handleEditMessage}
|
||||
onRestoreCheckpoint={handleRestoreCheckpoint}
|
||||
|
||||
@@ -15,6 +15,7 @@ const ToastViewport = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-105",
|
||||
className,
|
||||
|
||||
@@ -4,11 +4,13 @@ import { act, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import {
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
parseModelSelectionStorage,
|
||||
} from "@/lib/model-selection";
|
||||
import type { ProviderModel } from "@/lib/provider-schema";
|
||||
import {
|
||||
buildUserInstructionSlashCommands,
|
||||
ChatInputBar,
|
||||
@@ -27,7 +29,11 @@ const {
|
||||
current: null as MockSpeechInputProps | null,
|
||||
},
|
||||
startVercelStreamingTranscriptionMock: vi.fn(),
|
||||
subscribeToProviderModelsMock: vi.fn(() => vi.fn()),
|
||||
subscribeToProviderModelsMock: vi.fn<
|
||||
(
|
||||
listener: (providerId: string, models: ProviderModel[]) => void,
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
}));
|
||||
|
||||
type MockSpeechInputProps = {
|
||||
@@ -817,7 +823,7 @@ describe("ChatInputBar", () => {
|
||||
subscribeToProviderModelsMock.mock.calls[0]?.[0];
|
||||
await act(async () => {
|
||||
providerModelsListener?.("cline", [
|
||||
{ id: "refreshed-model", name: "Refreshed model" },
|
||||
{ id: "test-model", name: "Refreshed model" },
|
||||
]);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
@@ -1551,6 +1557,185 @@ describe("ChatInputBar", () => {
|
||||
window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY);
|
||||
});
|
||||
|
||||
const kimi: ProviderModel = {
|
||||
id: "cline-pass/kimi-k3",
|
||||
name: "Kimi K3",
|
||||
featured: { tier: "subscribed", rank: 0, tags: [] },
|
||||
};
|
||||
const flash: ProviderModel = {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
featured: { tier: "free", rank: 0, tags: [] },
|
||||
};
|
||||
|
||||
function mockBundledCatalog() {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline", "cline-pass"],
|
||||
providerModels: {
|
||||
cline: ["test-model"],
|
||||
"cline-pass": [flash.id],
|
||||
},
|
||||
providerModelDetails: { "cline-pass": [flash] },
|
||||
providerNames: { cline: "Cline", "cline-pass": "ClinePass" },
|
||||
providerReasoningModels: { cline: [], "cline-pass": [] },
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
"success",
|
||||
"failure",
|
||||
])("preserves the saved model through a delayed live catalog %s", async (outcome) => {
|
||||
mockBundledCatalog();
|
||||
const selection = {
|
||||
lastProvider: "cline-pass",
|
||||
lastModelByProvider: { "cline-pass": kimi.id },
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify(selection),
|
||||
);
|
||||
let resolveModels!: (models: ProviderModel[]) => void;
|
||||
let rejectModels!: (error: Error) => void;
|
||||
loadProviderModelsMock.mockReturnValue(
|
||||
new Promise<ProviderModel[]>((resolve, reject) => {
|
||||
resolveModels = resolve;
|
||||
rejectModels = reject;
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: kimi.id,
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
expect(loadProviderModelsMock).toHaveBeenCalledWith("cline-pass");
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(kimi.id);
|
||||
|
||||
await act(async () => {
|
||||
if (outcome === "success") resolveModels([flash, kimi]);
|
||||
else rejectModels(new Error("offline"));
|
||||
});
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(outcome === "success" ? kimi.name : kimi.id);
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
),
|
||||
).toEqual(selection);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"catalog refresh",
|
||||
"new chat",
|
||||
])("preserves an explicit pick through a %s with an incomplete catalog", async (transition) => {
|
||||
mockBundledCatalog();
|
||||
loadProviderModelsMock.mockResolvedValue([flash, kimi]);
|
||||
let publishModels!: (providerId: string, models: ProviderModel[]) => void;
|
||||
subscribeToProviderModelsMock.mockImplementation((listener) => {
|
||||
publishModels = listener;
|
||||
return vi.fn();
|
||||
});
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: flash.id,
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label^="Model:"]')
|
||||
?.click(),
|
||||
);
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('[role="option"]'),
|
||||
].find((entry) => entry.textContent?.includes(kimi.name));
|
||||
expect(option).toBeTruthy();
|
||||
await act(async () => option?.click());
|
||||
expect(onModelChange).toHaveBeenCalledWith(kimi.id);
|
||||
await renderComposer({
|
||||
model: kimi.id,
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
onModelChange.mockClear();
|
||||
if (transition === "new chat") {
|
||||
// New chat remounts the pane and seeds its config from storage.
|
||||
// The app stays open, but this picker has to load live models again.
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
const initial = getInitialChatConfig();
|
||||
expect(initial).toMatchObject({
|
||||
provider: "cline-pass",
|
||||
model: kimi.id,
|
||||
});
|
||||
let resolveModels!: (models: ProviderModel[]) => void;
|
||||
loadProviderModelsMock.mockReturnValue(
|
||||
new Promise<ProviderModel[]>((resolve) => {
|
||||
resolveModels = resolve;
|
||||
}),
|
||||
);
|
||||
await renderComposer({
|
||||
model: initial.model,
|
||||
provider: initial.provider,
|
||||
onModelChange,
|
||||
});
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(kimi.id);
|
||||
await act(async () => resolveModels([flash, kimi]));
|
||||
} else {
|
||||
await act(async () => publishModels("cline-pass", [flash]));
|
||||
}
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label^="Model:"]')?.textContent,
|
||||
).toContain(transition === "new chat" ? kimi.name : kimi.id);
|
||||
});
|
||||
|
||||
it("restores a remembered live model when switching back before live models load", async () => {
|
||||
mockBundledCatalog();
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: { cline: "test-model", "cline-pass": kimi.id },
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
const onProviderChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "test-model",
|
||||
provider: "cline",
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
});
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label^="Provider:"]')
|
||||
?.click(),
|
||||
);
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('[role="option"]'),
|
||||
].find((entry) => entry.textContent?.includes("ClinePass"));
|
||||
expect(option).toBeTruthy();
|
||||
await act(async () => option?.click());
|
||||
expect(onProviderChange).toHaveBeenCalledWith("cline-pass");
|
||||
expect(onModelChange).toHaveBeenCalledWith(kimi.id);
|
||||
expect(onModelChange).not.toHaveBeenCalledWith(flash.id);
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY),
|
||||
).lastModelByProvider["cline-pass"],
|
||||
).toBe(kimi.id);
|
||||
});
|
||||
|
||||
it("does not resurrect a stale remembered model the picker hides", async () => {
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
@@ -1582,6 +1767,25 @@ describe("ChatInputBar", () => {
|
||||
expect(panel?.textContent).not.toContain("Current model");
|
||||
});
|
||||
|
||||
it("does not apply another provider's remembered model to an empty selection", async () => {
|
||||
mockBundledCatalog();
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
lastProvider: "cline",
|
||||
lastModelByProvider: { cline: "test-model" },
|
||||
}),
|
||||
);
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
model: "",
|
||||
provider: "cline-pass",
|
||||
onModelChange,
|
||||
});
|
||||
expect(onModelChange).toHaveBeenCalledWith(flash.id);
|
||||
expect(onModelChange).not.toHaveBeenCalledWith("test-model");
|
||||
});
|
||||
|
||||
it("keeps an explicitly active out-of-offer model visible and selectable", async () => {
|
||||
const onModelChange = vi.fn();
|
||||
await renderComposer({
|
||||
|
||||
@@ -1618,21 +1618,29 @@ const ModelSelector = memo(function ModelSelector({
|
||||
[modelPicker],
|
||||
);
|
||||
const resolvedModel = useMemo(() => {
|
||||
if (modelsForProvider.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const rememberedModel =
|
||||
lastSelection.lastModelByProvider[resolvedProvider] ??
|
||||
lastSelection.lastModelByProvider[rememberedLastProvider];
|
||||
// An explicitly configured model stays active even when the picker's
|
||||
// offer hides it (the picker preserves it as a visible option below);
|
||||
// remembered and default selections are our own bookkeeping, so they
|
||||
// must resolve to a visible option — otherwise a stale remembered id
|
||||
// gets silently resurrected into a selection the picker cannot show.
|
||||
if (model && modelsForProvider.includes(model)) {
|
||||
(normalizeProviderId(rememberedLastProvider) === resolvedProvider
|
||||
? lastSelection.lastModelByProvider[rememberedLastProvider]
|
||||
: undefined);
|
||||
// Catalogs are discovery data, not validation: the bundled catalog can
|
||||
// omit live ClinePass models, and refreshes can return partial lists.
|
||||
// Keep the configured model for the current provider even if absent;
|
||||
// otherwise loading the catalog silently changes the session's model.
|
||||
if (
|
||||
model &&
|
||||
(normalizedProvider === resolvedProvider ||
|
||||
modelsForProvider.includes(model))
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
if (rememberedModel && pickerModelIds.has(rememberedModel)) {
|
||||
// Missing remembered models may also be live-only. Models present in
|
||||
// the catalog but deliberately hidden from the offer still fall back.
|
||||
if (
|
||||
rememberedModel &&
|
||||
(pickerModelIds.has(rememberedModel) ||
|
||||
!modelsForProvider.includes(rememberedModel))
|
||||
) {
|
||||
return rememberedModel;
|
||||
}
|
||||
return (
|
||||
@@ -1644,6 +1652,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
lastSelection.lastModelByProvider,
|
||||
model,
|
||||
modelsForProvider,
|
||||
normalizedProvider,
|
||||
pickerModelIds,
|
||||
rememberedLastProvider,
|
||||
resolvedProvider,
|
||||
@@ -1651,8 +1660,8 @@ const ModelSelector = memo(function ModelSelector({
|
||||
// The picker can intentionally hide catalog models (the ClinePass offer
|
||||
// is exactly its subscribed/free tiers), but the active model must stay
|
||||
// visible and selectable — e.g. a hydrated session configured with a
|
||||
// model outside the current offer. Surface it under its own section
|
||||
// rather than selecting a value that does not exist in the list.
|
||||
// model outside the current offer or missing from the catalog. Surface it
|
||||
// under its own section so the selected value always exists in the list.
|
||||
const visibleModelPicker = useMemo((): ModelPickerData => {
|
||||
if (!resolvedModel || pickerModelIds.has(resolvedModel)) {
|
||||
return modelPicker;
|
||||
@@ -1880,14 +1889,15 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
// Validate against the target provider's visible picker options,
|
||||
// not its full catalog: a remembered model the picker hides (e.g.
|
||||
// outside the ClinePass offer) must not become the selection.
|
||||
// Preserve live-only remembered models missing from the bundled
|
||||
// catalog. Only fall back when a known model is hidden by the offer.
|
||||
const providerOptionIds = new Set(
|
||||
pickerDataForProvider(value).options.map((option) => option.value),
|
||||
);
|
||||
const nextModel =
|
||||
rememberedModel && providerOptionIds.has(rememberedModel)
|
||||
rememberedModel &&
|
||||
(providerOptionIds.has(rememberedModel) ||
|
||||
!providerModelIds.includes(rememberedModel))
|
||||
? rememberedModel
|
||||
: (providerModelIds.find((id) => providerOptionIds.has(id)) ??
|
||||
providerModelIds[0]);
|
||||
@@ -1945,7 +1955,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
<SearchCombobox
|
||||
ariaLabel="Model"
|
||||
className={triggerClassName}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
disabled={isBusy || visibleModelPicker.options.length === 0}
|
||||
emptyText="No models found."
|
||||
onValueChange={(value) => {
|
||||
handleModelSelect(value);
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("ChatMessages tool disclosures", () => {
|
||||
}),
|
||||
createdAt: 1,
|
||||
};
|
||||
await renderMessages([pendingTool]);
|
||||
await renderMessages([pendingTool], { status: "running" });
|
||||
|
||||
const pendingTitle = container.querySelector(
|
||||
".cline-chat-tool-label > span",
|
||||
@@ -162,6 +162,93 @@ describe("ChatMessages tool disclosures", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"cancelled",
|
||||
"failed",
|
||||
"completed",
|
||||
"idle",
|
||||
] as const)("stops animating missing tool results when the run is %s, including on reopen", async (status) => {
|
||||
const tool: ChatMessage = {
|
||||
id: "unfinished",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
createdAt: 1,
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: null,
|
||||
}),
|
||||
meta: { toolName: "read_files", hookEventName: "tool_call_start" },
|
||||
};
|
||||
const snapshot = JSON.stringify(tool);
|
||||
await renderMessages([tool], { status: "running" });
|
||||
expect(
|
||||
container.querySelector(".cline-chat-streaming-title"),
|
||||
).not.toBeNull();
|
||||
await renderMessages([tool], { status });
|
||||
expect(container.querySelector(".cline-chat-streaming-title")).toBeNull();
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
expect(JSON.stringify(tool)).toBe(snapshot);
|
||||
await renderMessages(
|
||||
[{ ...tool, meta: { ...tool.meta, hookEventName: "history_tool_use" } }],
|
||||
{ status },
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-streaming-title")).toBeNull();
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
// A later turn must not reactivate the old unfinished tool.
|
||||
await renderMessages(
|
||||
[
|
||||
tool,
|
||||
{
|
||||
id: "next-turn",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Continue",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ status: "running" },
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps late results renderable after an inactive status", async () => {
|
||||
const tool: ChatMessage = {
|
||||
id: "late-result",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
createdAt: 1,
|
||||
content: JSON.stringify({
|
||||
toolName: "custom_tool",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: null,
|
||||
}),
|
||||
meta: { toolName: "custom_tool", hookEventName: "history_tool_use" },
|
||||
};
|
||||
await renderMessages([tool], { status: "completed" });
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
await renderMessages([tool], { status: "running" });
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).not.toBeNull();
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
...tool,
|
||||
content: JSON.stringify({
|
||||
toolName: "custom_tool",
|
||||
input: { paths: ["pending.ts"] },
|
||||
result: "Actual late result",
|
||||
}),
|
||||
meta: { ...tool.meta, hookEventName: "tool_call_end" },
|
||||
},
|
||||
],
|
||||
{ status: "running" },
|
||||
);
|
||||
expect(container.querySelector(".cline-chat-tool-progress")).toBeNull();
|
||||
const trigger = container.querySelector("button.cline-chat-tool-trigger");
|
||||
await act(async () => (trigger as HTMLButtonElement).click());
|
||||
expect(container.textContent).toContain("Actual late result");
|
||||
});
|
||||
|
||||
it("exposes and toggles expandable tool details", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
@@ -2117,4 +2204,25 @@ describe("ChatMessages tool approvals", () => {
|
||||
await act(async () => reject?.click());
|
||||
expect(onReject).toHaveBeenCalledWith("req-1");
|
||||
});
|
||||
|
||||
it("leads an imported transcript with a notice naming the source tool", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "imported prompt",
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
await renderMessages(messages, { importedFromTool: "claude-code" });
|
||||
|
||||
const notice = container.querySelector("output");
|
||||
expect(notice?.textContent).toContain("Imported from Claude Code");
|
||||
expect(notice?.parentElement?.firstElementChild).toBe(notice);
|
||||
expect(notice?.parentElement?.textContent).toContain("imported prompt");
|
||||
|
||||
await renderMessages(messages);
|
||||
expect(container.querySelector("output")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,9 @@ import type {
|
||||
ChatMessageImage,
|
||||
ChatSessionStatus,
|
||||
} from "@/lib/chat-schema";
|
||||
import type { SessionImportTool } from "@/lib/session-import";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImportedSessionNotice } from "./imported-session-notice";
|
||||
import { STREAMING_TITLE_CLASS } from "./messages/constants";
|
||||
import {
|
||||
buildPreviousTimestampMap,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
collapseCompletedWork,
|
||||
getThoughtDurationMilliseconds,
|
||||
groupChatMessages,
|
||||
isSystemSteeringMessage,
|
||||
} from "./messages/group-messages";
|
||||
import { ChatImageLightbox } from "./messages/image-lightbox";
|
||||
import { MessageBubble } from "./messages/message-bubble";
|
||||
@@ -57,6 +60,10 @@ type ChatMessagesProps = {
|
||||
isSessionSwitching?: boolean;
|
||||
messages: ChatMessage[];
|
||||
error: string | null;
|
||||
/** Set when the session's history was imported from another coding agent. */
|
||||
importedFromTool?: SessionImportTool;
|
||||
/** Replaces "Thinking..." while the runtime reports a named pre-output step. */
|
||||
activityLabel?: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
pendingToolApprovals: ToolApprovalRequestItem[];
|
||||
pendingAskQuestions: AskQuestionRequestItem[];
|
||||
@@ -99,6 +106,8 @@ function ChatMessagesImpl({
|
||||
isSessionSwitching = false,
|
||||
messages,
|
||||
error,
|
||||
importedFromTool,
|
||||
activityLabel = null,
|
||||
streamingMessageId = null,
|
||||
pendingToolApprovals,
|
||||
pendingAskQuestions,
|
||||
@@ -205,6 +214,14 @@ function ChatMessagesImpl({
|
||||
}),
|
||||
[messages, collapseTrailingRun],
|
||||
);
|
||||
const isRunActive =
|
||||
status === "starting" || status === "running" || status === "stopping";
|
||||
const lastUserItemIndex = renderItems.findLastIndex(
|
||||
(item) =>
|
||||
item.type === "message" &&
|
||||
item.message.role === "user" &&
|
||||
!isSystemSteeringMessage(item.message),
|
||||
);
|
||||
// Mid-run the thinking indicator's replacement (the next tool or thinking
|
||||
// row) joins the tight run group, so the indicator must sit at that same
|
||||
// tight offset; only at the start of a run, directly under the user
|
||||
@@ -532,6 +549,9 @@ function ChatMessagesImpl({
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-4">
|
||||
{importedFromTool ? (
|
||||
<ImportedSessionNotice tool={importedFromTool} />
|
||||
) : null}
|
||||
{renderItems.map((item, itemIndex) => {
|
||||
// Working rows — live (`run`) or folded (`work`) — render
|
||||
// through one child renderer so a row keeps its exact look
|
||||
@@ -543,6 +563,9 @@ function ChatMessagesImpl({
|
||||
if (child.type === "tools") {
|
||||
return (
|
||||
<ToolMessageBlock
|
||||
isRunActive={
|
||||
isRunActive && itemIndex > lastUserItemIndex
|
||||
}
|
||||
key={`tools_${child.messages[0]?.id ?? "empty"}`}
|
||||
messages={child.messages}
|
||||
onExpandImage={handleExpandImage}
|
||||
@@ -676,7 +699,9 @@ function ChatMessagesImpl({
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
|
||||
<span className={STREAMING_TITLE_CLASS}>
|
||||
{activityLabel ?? "Thinking..."}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Import } from "lucide-react";
|
||||
import {
|
||||
SESSION_IMPORT_TOOL_LABELS,
|
||||
type SessionImportTool,
|
||||
} from "@/lib/session-import";
|
||||
|
||||
/**
|
||||
* Heads a transcript imported from another coding agent. Its turns keep that
|
||||
* agent's tool names and schemas, which Cline does not translate; without the
|
||||
* notice the session looks native and the user has no way to know why
|
||||
* continuing it may go differently.
|
||||
*/
|
||||
export function ImportedSessionNotice({ tool }: { tool: SessionImportTool }) {
|
||||
const label = SESSION_IMPORT_TOOL_LABELS[tool];
|
||||
return (
|
||||
<output className="flex items-start gap-3 rounded-xl border border-amber-400/40 bg-amber-500/5 px-4 py-3">
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-500">
|
||||
<Import className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Imported from {label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
The earlier turns were recorded by {label}, whose tools and workflow
|
||||
differ from Cline's. When you continue, the model works from a
|
||||
summary of them rather than the original tool calls, so results may
|
||||
not be as reliable as in a session started with Cline.
|
||||
</p>
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
}
|
||||
@@ -61,15 +61,20 @@ function ToolLabel({
|
||||
|
||||
const ToolCallRow = memo(function ToolCallRow({
|
||||
message,
|
||||
isRunActive,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
isRunActive: boolean;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
const { payload, toolName, inProgress, summary } =
|
||||
buildToolPresentation(message);
|
||||
// A missing result can outlive its run. Only gate the running display;
|
||||
// keep the message intact so a later result can still replace it.
|
||||
const isRunning = inProgress && isRunActive;
|
||||
const isCommand = summary.kind === "command";
|
||||
// submit_and_exit carries the run's final answer (scheduled tasks end with
|
||||
// it), so surface it expanded and rendered as markdown rather than leaving
|
||||
@@ -103,7 +108,7 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
const toolSessionId = message.sessionId;
|
||||
const toolCallId = message.meta?.toolCallId;
|
||||
const canProceed = Boolean(
|
||||
inProgress &&
|
||||
isRunning &&
|
||||
isCommand &&
|
||||
message.meta?.toolDetachable === true &&
|
||||
toolSessionId &&
|
||||
@@ -216,9 +221,9 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={<ToolLabel isRunning={inProgress} parts={labelParts} />}
|
||||
label={<ToolLabel isRunning={isRunning} parts={labelParts} />}
|
||||
showDisclosureIcon={false}
|
||||
status={hasError ? "error" : inProgress ? "running" : "success"}
|
||||
status={hasError ? "error" : isRunning ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent presentation="rail">
|
||||
{details.length > 0 ? (
|
||||
@@ -253,10 +258,7 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
),
|
||||
)}
|
||||
{commandOutput ? (
|
||||
<CommandOutputTerminal
|
||||
isRunning={inProgress}
|
||||
output={commandOutput}
|
||||
/>
|
||||
<CommandOutputTerminal isRunning={isRunning} output={commandOutput} />
|
||||
) : submitText ? (
|
||||
// The summary is the run's final answer: full foreground color,
|
||||
// not the panel's muted tool-detail gray.
|
||||
@@ -391,10 +393,12 @@ function CommandOutputTerminal({
|
||||
export const ToolMessageBlock = memo(
|
||||
function ToolMessageBlock({
|
||||
messages,
|
||||
isRunActive,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isRunActive: boolean;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
@@ -403,6 +407,7 @@ export const ToolMessageBlock = memo(
|
||||
<div className="flex flex-col gap-1">
|
||||
{messages.map((message) => (
|
||||
<ToolCallRow
|
||||
isRunActive={isRunActive}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onExpandImage={onExpandImage}
|
||||
@@ -413,6 +418,7 @@ export const ToolMessageBlock = memo(
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.isRunActive === next.isRunActive &&
|
||||
prev.messages.length === next.messages.length &&
|
||||
prev.messages.every((message, index) => message === next.messages[index]) &&
|
||||
prev.onExpandImage === next.onExpandImage &&
|
||||
|
||||
@@ -43,6 +43,7 @@ function renderView({
|
||||
openThread = vi.fn(),
|
||||
loadAllSessions = vi.fn(async () => true),
|
||||
loadOlderSessions = vi.fn(),
|
||||
requestUsage = vi.fn(),
|
||||
mayHaveMoreSessions = false,
|
||||
threads = [thread],
|
||||
hasLoadedHistory = true,
|
||||
@@ -50,6 +51,7 @@ function renderView({
|
||||
openThread?: ReturnType<typeof vi.fn>;
|
||||
loadAllSessions?: ReturnType<typeof vi.fn>;
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
requestUsage?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
threads?: SessionThread[];
|
||||
hasLoadedHistory?: boolean;
|
||||
@@ -65,6 +67,7 @@ function renderView({
|
||||
openThread,
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
requestUsage,
|
||||
setThreadPinned: vi.fn(),
|
||||
sessionById: new Map(
|
||||
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
|
||||
@@ -76,6 +79,7 @@ function renderView({
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
openThread,
|
||||
requestUsage,
|
||||
render: () =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
@@ -308,6 +312,33 @@ describe("SessionsView pagination", () => {
|
||||
expect(container.textContent).toContain("11-20 of 25");
|
||||
});
|
||||
|
||||
it("asks the history hook for usage of the rows on the visible page", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith(
|
||||
manyThreads.slice(0, 10).map((item) => item.id),
|
||||
);
|
||||
|
||||
await clickNext();
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith(
|
||||
manyThreads.slice(10, 20).map((item) => item.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("releases its usage request when it unmounts", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith(
|
||||
manyThreads.slice(0, 10).map((item) => item.id),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<div />);
|
||||
});
|
||||
expect(view.requestUsage).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it("only asks the backend for older sessions at the last page", async () => {
|
||||
const view = renderView({
|
||||
threads: manyThreads,
|
||||
|
||||
@@ -241,6 +241,24 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
currentPage + 1 < pageCount ||
|
||||
(history.mayHaveMoreSessions && !requiresCompleteHistory);
|
||||
|
||||
// Tokens and cost are not part of the discovery rows; the hook reads them
|
||||
// from each transcript on demand, so tell it which rows are on screen.
|
||||
// Paging (or a fresh batch of older sessions) changes the visible rows and
|
||||
// the new page fills in the same way.
|
||||
useEffect(() => {
|
||||
history.requestUsage(visibleThreads.map((thread) => thread.id));
|
||||
}, [history.requestUsage, visibleThreads]);
|
||||
// Leaving the view releases its page, so running sessions on it stop being
|
||||
// re-read while nobody is looking at them. Separate from the effect above
|
||||
// on purpose: a per-change cleanup would clear and re-set the same ids and
|
||||
// restart the hook's hydration each time a row filled in.
|
||||
useEffect(
|
||||
() => () => {
|
||||
history.requestUsage([]);
|
||||
},
|
||||
[history.requestUsage],
|
||||
);
|
||||
|
||||
// Snap back when a page disappears (filters changed, or "next" asked the
|
||||
// backend for older sessions and there were none left).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
APP_ICONS,
|
||||
type AppIconId,
|
||||
appIconAssetPath,
|
||||
appIconSurface,
|
||||
DEFAULT_APP_ICON,
|
||||
readStoredAppIcon,
|
||||
setStoredAppIcon,
|
||||
@@ -679,6 +680,9 @@ function GeneralSettingsContent({
|
||||
if (typeof window === "undefined") return DEFAULT_APP_ICON;
|
||||
return readStoredAppIcon();
|
||||
});
|
||||
const [appIconLocation, setAppIconLocation] = useState<
|
||||
"Dock" | "Taskbar" | "desktop"
|
||||
>("desktop");
|
||||
const [appIconError, setAppIconError] = useState<string | null>(null);
|
||||
const appIconRequestRef = useRef(0);
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
@@ -701,6 +705,7 @@ function GeneralSettingsContent({
|
||||
>(null);
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
|
||||
useEffect(() => subscribeToAppFontSize(setFontSize), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -884,9 +889,6 @@ function GeneralSettingsContent({
|
||||
}
|
||||
setAppIcon(previousIcon);
|
||||
setAppIconError(error instanceof Error ? error.message : String(error));
|
||||
// Storage was written before the native call failed; roll it back
|
||||
// so the persisted choice matches what the dock actually shows.
|
||||
await setStoredAppIcon(previousIcon).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -996,7 +998,7 @@ function GeneralSettingsContent({
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">App icon</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick the icon Cline shows in the Dock.
|
||||
Pick the icon Cline shows in the {appIconLocation}.
|
||||
</p>
|
||||
{appIconError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { WindowControls } from "@/components/window-title-bar";
|
||||
|
||||
const windowMocks = vi.hoisted(() => ({
|
||||
close: vi.fn(),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
minimize: vi.fn(),
|
||||
onResized: vi.fn(async () => () => undefined),
|
||||
toggleMaximize: vi.fn(),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => windowMocks,
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({ isTauriAvailable: () => true }));
|
||||
|
||||
it("invokes caption actions without dismissing or taking pointer focus from a modal", async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
vi.stubGlobal("navigator", { userAgent: "Windows NT 10.0" });
|
||||
// Next hydrates document: React and Radix share its event listeners.
|
||||
const root = createRoot(document);
|
||||
const onOpenChange = vi.fn();
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body>
|
||||
<WindowControls />
|
||||
<Dialog defaultOpen onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Edit settings</DialogDescription>
|
||||
<input aria-label="Setting" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</body>
|
||||
</html>,
|
||||
);
|
||||
});
|
||||
// Radix registers its document pointer listener on the next timer turn.
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
expect(document.body.style.pointerEvents).toBe("none");
|
||||
const modal = document.querySelector('[role="dialog"]');
|
||||
const focused = document.activeElement;
|
||||
expect(modal?.contains(focused)).toBe(true);
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="window-controls"] button',
|
||||
);
|
||||
expect(buttons).toHaveLength(3);
|
||||
for (const button of buttons) {
|
||||
const pointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
button.dispatchEvent(pointerDown);
|
||||
button.click();
|
||||
});
|
||||
expect(pointerDown.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(focused);
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[role="dialog"]')).toBe(modal);
|
||||
}
|
||||
expect(windowMocks.minimize).toHaveBeenCalledOnce();
|
||||
expect(windowMocks.toggleMaximize).toHaveBeenCalledOnce();
|
||||
expect(windowMocks.close).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
@@ -2,18 +2,42 @@
|
||||
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
WindowTitleBar,
|
||||
WindowTitleBarContent,
|
||||
WindowTitleBarProvider,
|
||||
} from "@/components/window-title-bar";
|
||||
|
||||
const windowMocks = vi.hoisted(() => ({
|
||||
close: vi.fn(),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
minimize: vi.fn(),
|
||||
onResized: vi.fn(async (_listener: () => void) => () => undefined),
|
||||
toggleMaximize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => windowMocks,
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
isTauriAvailable: () => "__TAURI_INTERNALS__" in window,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
windowMocks.close.mockReset();
|
||||
windowMocks.isMaximized.mockReset();
|
||||
windowMocks.isMaximized.mockResolvedValue(false);
|
||||
windowMocks.minimize.mockReset();
|
||||
windowMocks.onResized.mockReset();
|
||||
windowMocks.onResized.mockResolvedValue(() => undefined);
|
||||
windowMocks.toggleMaximize.mockReset();
|
||||
delete (window as Window & { __TAURI_INTERNALS__?: unknown })
|
||||
.__TAURI_INTERNALS__;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -21,6 +45,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
delete (window as Window & { __TAURI_INTERNALS__?: unknown })
|
||||
.__TAURI_INTERNALS__;
|
||||
vi.unstubAllGlobals();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -56,6 +83,115 @@ function renderShell(contentEnabled: boolean) {
|
||||
}
|
||||
|
||||
describe("WindowTitleBar", () => {
|
||||
it("renders Windows caption controls and invokes the native window actions", async () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
vi.stubGlobal("navigator", { userAgent: "Windows NT 10.0" });
|
||||
windowMocks.isMaximized.mockResolvedValue(true);
|
||||
|
||||
await act(async () => root.render(renderShell(true)));
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
const controls = container.querySelector<HTMLElement>(
|
||||
'[data-slot="window-controls"]',
|
||||
);
|
||||
expect(controls).not.toBeNull();
|
||||
if (!controls) {
|
||||
throw new Error("Expected Windows caption controls");
|
||||
}
|
||||
expect(controls.querySelector('[aria-label="Restore"]')).not.toBeNull();
|
||||
await act(async () => {
|
||||
controls
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Minimize"]')
|
||||
?.click();
|
||||
controls
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Restore"]')
|
||||
?.click();
|
||||
controls
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Close"]')
|
||||
?.click();
|
||||
});
|
||||
expect(windowMocks.minimize).toHaveBeenCalledOnce();
|
||||
expect(windowMocks.toggleMaximize).toHaveBeenCalledOnce();
|
||||
expect(windowMocks.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("tracks the maximize state across window resizes", async () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
vi.stubGlobal("navigator", { userAgent: "Windows NT 10.0" });
|
||||
|
||||
await act(async () => root.render(renderShell(true)));
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
const controls = container.querySelector<HTMLElement>(
|
||||
'[data-slot="window-controls"]',
|
||||
);
|
||||
expect(controls).not.toBeNull();
|
||||
if (!controls) {
|
||||
throw new Error("Expected Windows caption controls");
|
||||
}
|
||||
const toggleButtonLabel = () =>
|
||||
controls
|
||||
.querySelector(
|
||||
'button[aria-label="Maximize"], button[aria-label="Restore"]',
|
||||
)
|
||||
?.getAttribute("aria-label");
|
||||
expect(toggleButtonLabel()).toBe("Maximize");
|
||||
|
||||
const onResized = windowMocks.onResized.mock.calls.at(-1)?.[0];
|
||||
expect(onResized).toBeTypeOf("function");
|
||||
if (!onResized) {
|
||||
throw new Error("Expected a resize listener");
|
||||
}
|
||||
|
||||
windowMocks.isMaximized.mockResolvedValue(true);
|
||||
await act(async () => {
|
||||
onResized();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(toggleButtonLabel()).toBe("Restore");
|
||||
|
||||
windowMocks.isMaximized.mockResolvedValue(false);
|
||||
await act(async () => {
|
||||
onResized();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(toggleButtonLabel()).toBe("Maximize");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "Windows browser", tauri: false, userAgent: "Windows NT 10.0" },
|
||||
{
|
||||
name: "macOS desktop",
|
||||
tauri: true,
|
||||
userAgent: "Macintosh; Intel Mac OS X 10_15_7",
|
||||
},
|
||||
{ name: "Linux desktop", tauri: true, userAgent: "X11; Linux x86_64" },
|
||||
])("does not render caption controls in $name", async ({
|
||||
tauri,
|
||||
userAgent,
|
||||
}) => {
|
||||
if (tauri) {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
}
|
||||
vi.stubGlobal("navigator", { userAgent });
|
||||
await act(async () => root.render(renderShell(true)));
|
||||
expect(container.querySelector('[data-slot="window-controls"]')).toBeNull();
|
||||
expect(
|
||||
document.documentElement.hasAttribute("data-windows-custom-titlebar"),
|
||||
).toBe(false);
|
||||
expect(windowMocks.isMaximized).not.toHaveBeenCalled();
|
||||
expect(windowMocks.onResized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reserves an in-flow draggable row before page content inside main", async () => {
|
||||
await act(async () => root.render(renderShell(false)));
|
||||
|
||||
@@ -63,7 +199,7 @@ describe("WindowTitleBar", () => {
|
||||
const titleBar = main?.querySelector('[data-slot="window-title-bar"]');
|
||||
const page = main?.querySelector('[data-testid="page"]');
|
||||
expect(titleBar?.getAttribute("data-tauri-drag-region")).toBe("deep");
|
||||
expect(titleBar?.className).toContain("h-12");
|
||||
expect(titleBar?.className).toContain("h-(--window-title-bar-height)");
|
||||
expect(titleBar?.className).toContain("shrink-0");
|
||||
expect(titleBar?.nextElementSibling).toBe(page);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Minus, Square, X } from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { isTauriAvailable } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WindowTitleBarContextValue = {
|
||||
@@ -31,10 +40,92 @@ export function WindowTitleBarProvider({
|
||||
value={{ contentEnabled, portalTarget, setPortalTarget }}
|
||||
>
|
||||
{children}
|
||||
<WindowControls />
|
||||
</WindowTitleBarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function isWindowsDesktop(): boolean {
|
||||
return (
|
||||
isTauriAvailable() &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Windows/i.test(navigator.userAgent)
|
||||
);
|
||||
}
|
||||
|
||||
/** Native window actions for the borderless Windows frame. */
|
||||
export function WindowControls() {
|
||||
const [isWindows, setIsWindows] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindowsDesktop()) {
|
||||
return;
|
||||
}
|
||||
setIsWindows(true);
|
||||
document.documentElement.dataset.windowsCustomTitlebar = "";
|
||||
const appWindow = getCurrentWindow();
|
||||
void appWindow.isMaximized().then(setIsMaximized);
|
||||
const unlisten = appWindow.onResized(() => {
|
||||
void appWindow.isMaximized().then(setIsMaximized);
|
||||
});
|
||||
return () => {
|
||||
delete document.documentElement.dataset.windowsCustomTitlebar;
|
||||
void unlisten.then((stopListening) => stopListening());
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isWindows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto fixed top-0 right-0 z-[110] flex h-(--window-title-bar-height) bg-background"
|
||||
data-slot="window-controls"
|
||||
onPointerDownCapture={(event) => {
|
||||
// Caption actions stay above app overlays, even when a modal disables
|
||||
// body pointer events. Keep its focus and outside-click state unchanged.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label="Minimize"
|
||||
className="flex w-12 items-center justify-center text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onClick={() => void appWindow.minimize()}
|
||||
type="button"
|
||||
>
|
||||
<Minus aria-hidden="true" className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
className="flex w-12 items-center justify-center text-foreground hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onClick={() => void appWindow.toggleMaximize()}
|
||||
type="button"
|
||||
>
|
||||
{isMaximized ? (
|
||||
<span aria-hidden="true" className="relative size-3.5">
|
||||
<span className="absolute top-0 right-0 size-2.5 border border-current" />
|
||||
<span className="absolute bottom-0 left-0 size-2.5 border border-current bg-background" />
|
||||
</span>
|
||||
) : (
|
||||
<Square aria-hidden="true" className="size-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="flex w-12 items-center justify-center text-foreground hover:bg-red-600 hover:text-white focus-visible:bg-red-600 focus-visible:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
|
||||
onClick={() => void appWindow.close()}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves the native title-bar row at the shell boundary. The normal app
|
||||
* shell hosts projected controls; full-screen shell overlays only need the
|
||||
@@ -56,7 +147,10 @@ export function WindowTitleBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("isolate h-12 shrink-0 max-md:h-7", className)}
|
||||
className={cn(
|
||||
"isolate h-(--window-title-bar-height) shrink-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="window-title-bar"
|
||||
data-tauri-drag-region="deep"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,12 @@ export type AgentChunkEvent = {
|
||||
chunk: string;
|
||||
ts: number;
|
||||
index?: number;
|
||||
/**
|
||||
* Identifies the sidecar process that numbered this chunk. `index` restarts
|
||||
* whenever the sidecar does, so a changed `boot` means the counter reset
|
||||
* rather than the stream replaying.
|
||||
*/
|
||||
boot?: string;
|
||||
};
|
||||
|
||||
export type ReasoningDeltaEvent = {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
MAX_LIVE_COMMAND_OUTPUT_CHARS,
|
||||
} from "@/lib/command-output";
|
||||
import { MODEL_SELECTION_STORAGE_KEY } from "@/lib/model-selection";
|
||||
import {
|
||||
buildPreviousTimestampMap,
|
||||
getThoughtDurationMilliseconds,
|
||||
} from "../components/views/chat/messages/group-messages";
|
||||
import { useChatSession } from "./use-chat-session";
|
||||
|
||||
const { invokeMock, subscribeMock } = vi.hoisted(() => ({
|
||||
@@ -108,6 +112,113 @@ describe("useChatSession", () => {
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps a new task on starting while the hub reports the just-created session idle", async () => {
|
||||
// The hub publishes session.created / session.updated with the record's
|
||||
// "idle" status during the start RPC, before the first prompt's run
|
||||
// begins. Applying it over "starting" flipped the composer placeholder
|
||||
// and hid the request indicator for a frame on every new task.
|
||||
const startResponse = deferred<{ cwd: string; workspaceRoot: string }>();
|
||||
let plannedSessionId = "";
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
// The sidecar reuses the planned id the webview passes.
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
return {
|
||||
...(await startResponse.promise),
|
||||
sessionId: plannedSessionId,
|
||||
};
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: [{ id: "p1", prompt: "hello", steer: false }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let sendTask: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
sendTask = current.sendPrompt("hello");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.status).toBe("starting");
|
||||
const statusHandler = handlerFor("chat_session_status");
|
||||
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId: plannedSessionId, status: "idle" });
|
||||
statusHandler({ sessionId: plannedSessionId, status: "idle" });
|
||||
});
|
||||
expect(current.status).toBe("starting");
|
||||
|
||||
await act(async () => {
|
||||
startResponse.resolve({
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
});
|
||||
await sendTask;
|
||||
});
|
||||
expect(current.status).toBe("running");
|
||||
|
||||
// Once nothing is in flight, the hub's status applies as before.
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId: plannedSessionId, status: "idle" });
|
||||
});
|
||||
expect(current.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("still applies a terminal status that lands while a submission is in flight", async () => {
|
||||
// Only the transient "idle" is held back; a failed session must unstick
|
||||
// the UI even if the send response never arrives.
|
||||
const startResponse = deferred<{ cwd: string; workspaceRoot: string }>();
|
||||
let plannedSessionId = "";
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
plannedSessionId = request.config?.sessionId ?? "";
|
||||
return {
|
||||
...(await startResponse.promise),
|
||||
sessionId: plannedSessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
void current.sendPrompt("hello");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.status).toBe("starting");
|
||||
const statusHandler = handlerFor("chat_session_status");
|
||||
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId: plannedSessionId, status: "failed" });
|
||||
});
|
||||
expect(current.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("preserves authoritative completion across abort races", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -1425,7 +1536,7 @@ describe("useChatSession", () => {
|
||||
expect(userMessages[1]?.id).toBe("queued_user_queued-prompt-2");
|
||||
});
|
||||
|
||||
it("keeps live stream timestamps in milliseconds", async () => {
|
||||
it("stamps live rows on the webview clock rather than the sidecar timestamp", async () => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
@@ -1457,7 +1568,9 @@ describe("useChatSession", () => {
|
||||
);
|
||||
expect(chatEventHandler).toBeDefined();
|
||||
expect(userMessage).toBeDefined();
|
||||
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) + 5_000;
|
||||
// A sidecar `ts` from a different clock must not leak into the row.
|
||||
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) - 60_000;
|
||||
const before = Date.now();
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler?.({
|
||||
@@ -1469,10 +1582,12 @@ describe("useChatSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
current.messages.find((message) => message.role === "assistant")
|
||||
?.createdAt,
|
||||
).toBe(thinkingTimestamp);
|
||||
const assistantCreatedAt = current.messages.find(
|
||||
(message) => message.role === "assistant",
|
||||
)?.createdAt;
|
||||
expect(assistantCreatedAt).not.toBe(thinkingTimestamp);
|
||||
expect(assistantCreatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(assistantCreatedAt).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it("updates current token usage from live usage events", async () => {
|
||||
@@ -1537,6 +1652,193 @@ describe("useChatSession", () => {
|
||||
expect(current.summary.totalCostUsd).toBeCloseTo(0.03);
|
||||
});
|
||||
|
||||
it("keeps a queued prompt's user bubble when the preceding blocking send resolves after it starts", async () => {
|
||||
const sessionId = "session-queued-bubble";
|
||||
const sendResponse = deferred<unknown>();
|
||||
// The runtime persists the transcript at iteration boundaries, so while
|
||||
// the queued turn is in flight the canonical read still ends at the
|
||||
// previous turn's assistant message.
|
||||
const canonicalAfterFirstTurn = [
|
||||
{
|
||||
id: "canonical-user-1",
|
||||
sessionId,
|
||||
role: "user",
|
||||
content: "write an essay",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "canonical-assistant-1",
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content: "Here is the essay.",
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "read_session_messages") {
|
||||
return canonicalAfterFirstTurn;
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") return { sessionId };
|
||||
if (request?.action === "send") return await sendResponse.promise;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
let sendTask: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
sendTask = current.sendPrompt("write an essay");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const chatEventHandler = handlerFor("chat_event");
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "Here is the essay.",
|
||||
ts: Date.now(),
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// The runtime drains the queue before it answers the blocking send:
|
||||
// the next prompt's start event lands first.
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_queued_prompt_start",
|
||||
chunk: JSON.stringify({
|
||||
promptId: "queued-how",
|
||||
prompt: "how are you?",
|
||||
attachmentCount: 0,
|
||||
}),
|
||||
ts: Date.now(),
|
||||
index: 2,
|
||||
});
|
||||
});
|
||||
expect(
|
||||
current.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" && message.content === "how are you?",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
sendResponse.resolve({
|
||||
ok: true,
|
||||
result: { text: "Here is the essay.", finishReason: "completed" },
|
||||
});
|
||||
await sendTask;
|
||||
});
|
||||
|
||||
// The queued turn is in flight: its request indicator depends on the
|
||||
// session staying "running", and the late response must not settle
|
||||
// the new turn's epoch either (the hub's "running" for it would then
|
||||
// read as stale).
|
||||
expect(current.status).toBe("running");
|
||||
const statusHandler = handlerFor("chat_session_status");
|
||||
await act(async () => {
|
||||
statusHandler({ sessionId, status: "running" });
|
||||
});
|
||||
expect(current.status).toBe("running");
|
||||
|
||||
const userContents = current.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.content);
|
||||
expect(userContents).toEqual(["write an essay", "how are you?"]);
|
||||
// The previous turn's assistant text was already streamed into its own
|
||||
// bubble; the late response must not append a second copy below the
|
||||
// queued prompt.
|
||||
expect(
|
||||
current.messages.filter(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
message.content === "Here is the essay.",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(current.messages.at(-1)).toMatchObject({
|
||||
id: "queued_user_queued-how",
|
||||
role: "user",
|
||||
content: "how are you?",
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps live rows on the webview clock so a sidecar clock behind the browser cannot erase a thought duration", async () => {
|
||||
const sessionId = "session-clock-skew";
|
||||
const sendResponse = deferred<unknown>();
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") return { sessionId };
|
||||
if (request?.action === "send") return await sendResponse.promise;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
await act(async () => {
|
||||
void current.sendPrompt("write an essay");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const chatEventHandler = handlerFor("chat_event");
|
||||
// The sidecar's clock trails the browser by 15s: its `ts` predates the
|
||||
// optimistic user bubble that was appended on the browser clock.
|
||||
const sidecarTs = Date.now() - 15_000;
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_reasoning",
|
||||
chunk: JSON.stringify({ text: "Let me think.", redacted: false }),
|
||||
ts: sidecarTs,
|
||||
index: 1,
|
||||
});
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "Here is the essay.",
|
||||
ts: sidecarTs + 1,
|
||||
index: 2,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
const userBubble = current.messages.find(
|
||||
(message) =>
|
||||
message.role === "user" && message.content === "write an essay",
|
||||
);
|
||||
const assistant = current.messages.find(
|
||||
(message) => message.role === "assistant" && message.reasoning,
|
||||
);
|
||||
expect(userBubble).toBeDefined();
|
||||
expect(assistant).toBeDefined();
|
||||
expect(assistant?.createdAt).toBeGreaterThanOrEqual(
|
||||
userBubble?.createdAt ?? 0,
|
||||
);
|
||||
const previous = assistant
|
||||
? buildPreviousTimestampMap(current.messages).get(assistant)
|
||||
: undefined;
|
||||
expect(
|
||||
getThoughtDurationMilliseconds(previous, assistant?.createdAt ?? 0),
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves consecutive queued costs while the preceding turn is persisted", async () => {
|
||||
type SendResponse = {
|
||||
ok: true;
|
||||
@@ -3368,4 +3670,102 @@ describe("coerced-queue first turn vs stale send response", () => {
|
||||
expect(current.promptsInQueue).toHaveLength(1);
|
||||
expect(current.promptsInQueue[0]?.prompt).toBe("second prompt");
|
||||
});
|
||||
|
||||
it("keeps rendering the live stream after the sidecar restarts its chunk index", async () => {
|
||||
const sessionId = "session-sidecar-restart";
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") return { sessionId };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
const chatEventHandler = handlerFor("chat_event");
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "before ",
|
||||
ts: Date.now(),
|
||||
index: 42,
|
||||
boot: "boot-a",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
|
||||
// A replacement sidecar numbers its chunks from 1 again. Without the
|
||||
// boot id the high-water mark would swallow the rest of the session.
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "after",
|
||||
ts: Date.now(),
|
||||
index: 1,
|
||||
boot: "boot-b",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
|
||||
const assistantText = current.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => message.content)
|
||||
.join("");
|
||||
expect(assistantText).toContain("before ");
|
||||
expect(assistantText).toContain("after");
|
||||
});
|
||||
|
||||
it("still drops a chunk the same sidecar already delivered", async () => {
|
||||
const sessionId = "session-replayed-chunk";
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as { action?: string } | undefined;
|
||||
if (request?.action === "start") return { sessionId };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => current.start(current.config));
|
||||
const chatEventHandler = handlerFor("chat_event");
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "kept",
|
||||
ts: Date.now(),
|
||||
index: 7,
|
||||
boot: "boot-a",
|
||||
});
|
||||
chatEventHandler({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "replayed",
|
||||
ts: Date.now(),
|
||||
index: 7,
|
||||
boot: "boot-a",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
|
||||
const assistantText = current.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => message.content)
|
||||
.join("");
|
||||
expect(assistantText).toContain("kept");
|
||||
expect(assistantText).not.toContain("replayed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ import type {
|
||||
SessionHistoryItem,
|
||||
SessionHistoryStatus,
|
||||
} from "@/lib/session-history";
|
||||
import { readImportedHistorySummaryActivity } from "@/lib/session-import";
|
||||
import {
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
@@ -158,8 +159,17 @@ function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
|
||||
});
|
||||
}
|
||||
|
||||
function chunkCreatedAt(payload: AgentChunkEvent): number {
|
||||
return payload.ts || Date.now();
|
||||
// Live rows are stamped on this process's clock, not the sidecar's `ts`.
|
||||
// Every timestamp a live row is compared against comes from here: the
|
||||
// optimistic user bubble a send appends, `hydrationStartedAt`,
|
||||
// `turnStartedAt`, and the preceding row in the thought-duration
|
||||
// subtraction. The sidecar can run on another machine's clock (the
|
||||
// browser-dev container, a remote hub), and mixing the two turned a
|
||||
// finished reasoning row into a durationless "Thinking" whenever that clock
|
||||
// trailed the browser by more than the time to first token. Persisted rows
|
||||
// keep the runtime's timestamps and are consistent among themselves.
|
||||
function chunkCreatedAt(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function mergeHydratedMessagesWithLive(options: {
|
||||
@@ -378,6 +388,9 @@ export function useChatSession() {
|
||||
const [activeAssistantMessageId, setActiveAssistantMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
// Names what the runtime is doing before the first output of a turn (in
|
||||
// place of "Thinking..."); ephemeral, cleared when the turn moves on.
|
||||
const [activityLabel, setActivityLabel] = useState<string | null>(null);
|
||||
const [hydratedHistorySessionId, setHydratedHistorySessionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -408,6 +421,7 @@ export function useChatSession() {
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const activeAssistantMessageIdRef = useRef<string | null>(null);
|
||||
const lastStreamIndexBySessionRef = useRef<Record<string, number>>({});
|
||||
const lastStreamBootBySessionRef = useRef<Record<string, string>>({});
|
||||
const abortedRef = useRef(false);
|
||||
const abortFallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
@@ -518,9 +532,11 @@ export function useChatSession() {
|
||||
const resetStreamDedupe = useCallback((targetSessionId?: string | null) => {
|
||||
if (targetSessionId) {
|
||||
delete lastStreamIndexBySessionRef.current[targetSessionId];
|
||||
delete lastStreamBootBySessionRef.current[targetSessionId];
|
||||
return;
|
||||
}
|
||||
lastStreamIndexBySessionRef.current = {};
|
||||
lastStreamBootBySessionRef.current = {};
|
||||
}, []);
|
||||
|
||||
const clearAbortFallbackTimeout = useCallback(() => {
|
||||
@@ -816,6 +832,21 @@ export function useChatSession() {
|
||||
if (typeof index !== "number") {
|
||||
return true;
|
||||
}
|
||||
// `index` counts up inside one sidecar process. When the sidecar is
|
||||
// replaced under a live webview the counter restarts at 1, and without
|
||||
// this the high-water mark below would silently discard the whole
|
||||
// stream — user messages and tool rows included — until the new
|
||||
// process counted past the old run.
|
||||
const boot = payload.boot;
|
||||
if (boot !== undefined) {
|
||||
const previousBoot =
|
||||
lastStreamBootBySessionRef.current[payload.sessionId];
|
||||
if (previousBoot !== boot) {
|
||||
lastStreamBootBySessionRef.current[payload.sessionId] = boot;
|
||||
lastStreamIndexBySessionRef.current[payload.sessionId] = index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const previous = lastStreamIndexBySessionRef.current[payload.sessionId];
|
||||
if (previous !== undefined && index <= previous) {
|
||||
return false;
|
||||
@@ -1256,7 +1287,7 @@ export function useChatSession() {
|
||||
sessionId: listeningSessionId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
createdAt: chunkCreatedAt(),
|
||||
});
|
||||
activeAssistantMessageIdRef.current = assistantId;
|
||||
setActiveAssistantMessageId(assistantId);
|
||||
@@ -1280,7 +1311,7 @@ export function useChatSession() {
|
||||
sessionId: listeningSessionId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
createdAt: chunkCreatedAt(),
|
||||
});
|
||||
activeAssistantMessageIdRef.current = assistantId;
|
||||
setActiveAssistantMessageId(assistantId);
|
||||
@@ -1383,7 +1414,7 @@ export function useChatSession() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
media: [media.data],
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
createdAt: chunkCreatedAt(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1395,6 +1426,7 @@ export function useChatSession() {
|
||||
if (payload.stream === "chat_queued_prompt_start") {
|
||||
activeTurnCostTrackerRef.current = { streamedCostUsd: 0 };
|
||||
turnEpochRef.current += 1;
|
||||
setActivityLabel(null);
|
||||
// A new turn starts now: an error remembered from an earlier turn
|
||||
// must not be attributed to this one if it fails without detail.
|
||||
delete lastCoreErrorBySessionRef.current[listeningSessionId];
|
||||
@@ -1514,7 +1546,7 @@ export function useChatSession() {
|
||||
role: "user",
|
||||
content: userLabel,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
createdAt: chunkCreatedAt(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1537,6 +1569,18 @@ export function useChatSession() {
|
||||
lastCoreErrorBySessionRef.current[payload.sessionId] =
|
||||
parsed.message.trim();
|
||||
}
|
||||
// An imported session's history is summarized before the first
|
||||
// model call; name that wait instead of showing "Thinking...".
|
||||
const summaryActivity = readImportedHistorySummaryActivity(
|
||||
parsed.metadata,
|
||||
);
|
||||
if (summaryActivity) {
|
||||
setActivityLabel(
|
||||
summaryActivity.phase === "started"
|
||||
? summaryActivity.label
|
||||
: null,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Unstructured logs carry no level; nothing to remember.
|
||||
}
|
||||
@@ -1573,6 +1617,7 @@ export function useChatSession() {
|
||||
}
|
||||
|
||||
if (payload.stream === "chat_done") {
|
||||
setActivityLabel(null);
|
||||
// The turn is over: any optimistic bubble still registered was
|
||||
// consumed by a direct send and must not be re-keyed by a later
|
||||
// queued prompt that happens to repeat the same text. Clear
|
||||
@@ -1660,7 +1705,7 @@ export function useChatSession() {
|
||||
input: parsed.input,
|
||||
output: null,
|
||||
}),
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
createdAt: chunkCreatedAt(),
|
||||
meta: {
|
||||
toolName,
|
||||
toolCallId,
|
||||
@@ -1786,6 +1831,20 @@ export function useChatSession() {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The reverse case: the hub publishes the session record's status
|
||||
// as soon as the session is created, before the first prompt's
|
||||
// run starts, so an "idle" that lands while a local submission is
|
||||
// still in flight predates the run it is about to start. Applying
|
||||
// it flipped the composer and the request indicator from
|
||||
// "starting" to idle and back for a frame on every new task. The
|
||||
// submission owns status until it hands off (the queued-start
|
||||
// event, or its own completion for a blocking send). Only "idle"
|
||||
// is held back: a terminal status (failed, aborted) during a
|
||||
// submission is real and must still unstick the UI even if the
|
||||
// send response never arrives.
|
||||
if (nextStatus === "idle" && activePromptSubmissionsRef.current > 0) {
|
||||
return;
|
||||
}
|
||||
authoritativeStatusRevisionRef.current += 1;
|
||||
setStatus(nextStatus as ChatSessionStatus);
|
||||
},
|
||||
@@ -2294,6 +2353,17 @@ export function useChatSession() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The runtime drains the queue before it answers a blocking send,
|
||||
// so the next queued prompt's chat_queued_prompt_start can reach
|
||||
// the webview ahead of this response. That event bumps the epoch
|
||||
// and its turn owns the transcript from then on: its user bubble
|
||||
// exists only in live state until the runtime persists it at the
|
||||
// end of that turn, so replacing the transcript from a canonical
|
||||
// read here would erase it. The newer turn's own completion
|
||||
// reconciles history when it ends.
|
||||
const newerTurnOwnsTranscript = () =>
|
||||
turnEpochRef.current !== turnEpochAtDispatch;
|
||||
|
||||
const result = payload.result as ChatApiResult | undefined;
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
if (settleAbortedSend()) return;
|
||||
@@ -2308,7 +2378,7 @@ export function useChatSession() {
|
||||
);
|
||||
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
|
||||
const resolvedAssistantText = rawAssistantText;
|
||||
if (resolvedAssistantText) {
|
||||
if (resolvedAssistantText && !newerTurnOwnsTranscript()) {
|
||||
const assistantMessageId =
|
||||
activeAssistantMessageIdRef.current ?? makeId("assistant");
|
||||
activeAssistantMessageIdRef.current = assistantMessageId;
|
||||
@@ -2421,7 +2491,7 @@ export function useChatSession() {
|
||||
});
|
||||
}
|
||||
const fallbackMedia = fallbackAssistantTurn.media;
|
||||
if (fallbackMedia.length > 0) {
|
||||
if (fallbackMedia.length > 0 && !newerTurnOwnsTranscript()) {
|
||||
setMessages((prev) => {
|
||||
const knownIds = new Set(
|
||||
prev.flatMap((message) =>
|
||||
@@ -2452,21 +2522,25 @@ export function useChatSession() {
|
||||
fallbackAssistantTurn.reasoningRedacted ||
|
||||
fallbackImages.length > 0 ||
|
||||
fallbackMedia.length > 0;
|
||||
if (!hasFallbackAssistantTurn) {
|
||||
if (!hasFallbackAssistantTurn && !newerTurnOwnsTranscript()) {
|
||||
// Recovery: load canonical messages if transport missed result text.
|
||||
try {
|
||||
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
|
||||
"read_session_messages",
|
||||
{ sessionId: activeSessionId, maxMessages: MAX_MESSAGES },
|
||||
);
|
||||
if (historyMessages.length > 0) {
|
||||
if (historyMessages.length > 0 && !newerTurnOwnsTranscript()) {
|
||||
applyCanonicalHistory(activeSessionId, historyMessages);
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic state if hydration read fails.
|
||||
}
|
||||
}
|
||||
if (Array.isArray(result?.toolCalls) && result.toolCalls.length > 0) {
|
||||
if (
|
||||
Array.isArray(result?.toolCalls) &&
|
||||
result.toolCalls.length > 0 &&
|
||||
!newerTurnOwnsTranscript()
|
||||
) {
|
||||
materializeToolMessagesFromResult({
|
||||
sessionId: activeSessionId,
|
||||
turnStartedAt: now,
|
||||
@@ -2484,6 +2558,7 @@ export function useChatSession() {
|
||||
);
|
||||
if (
|
||||
historyMessages.length > 0 &&
|
||||
!newerTurnOwnsTranscript() &&
|
||||
(!hasFallbackAssistantTurn || hasCanonicalAssistantTurn)
|
||||
) {
|
||||
const assistantIndex = historyMessages.findLastIndex(
|
||||
@@ -2594,6 +2669,14 @@ export function useChatSession() {
|
||||
Array.isArray(payload.promptsInQueue) &&
|
||||
payload.promptsInQueue.length > 0;
|
||||
if (settleAbortedSend()) return;
|
||||
// A queued prompt that already started its turn owns the status
|
||||
// and the settled epoch from here: its start set "running", and
|
||||
// its own completion settles it. Settling this turn on top of it
|
||||
// flipped the composer to "completed" while the reply was still
|
||||
// pending (no request indicator at all) and marked the new epoch
|
||||
// settled, so the hub's "running" for that turn then read as
|
||||
// stale and was dropped.
|
||||
const newerTurnOwnsStatus = newerTurnOwnsTranscript();
|
||||
if (result?.finishReason === "error") {
|
||||
// On a failed run result.text is the runtime's error string
|
||||
// (never assistant content — see isErrorResult above), so it
|
||||
@@ -2607,11 +2690,17 @@ export function useChatSession() {
|
||||
activeSessionId,
|
||||
runError || toolError?.trim() || "",
|
||||
);
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("failed");
|
||||
if (!newerTurnOwnsStatus) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("failed");
|
||||
}
|
||||
} else if (result?.finishReason === "aborted") {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
if (!newerTurnOwnsStatus) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
}
|
||||
} else if (newerTurnOwnsStatus) {
|
||||
// Leave status to the turn in flight.
|
||||
} else if (hasQueuedFollowUps) {
|
||||
setStatus("running");
|
||||
} else {
|
||||
@@ -2629,7 +2718,10 @@ export function useChatSession() {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
} finally {
|
||||
clearAbortFallbackTimeout();
|
||||
if (!shouldQueue) {
|
||||
// If a queued prompt already started its turn, these refs are that
|
||||
// turn's now (chat_queued_prompt_start reset them for it); clearing
|
||||
// them here would split its streaming assistant bubble.
|
||||
if (!shouldQueue && turnEpochRef.current === turnEpochAtDispatch) {
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
@@ -2862,6 +2954,7 @@ export function useChatSession() {
|
||||
lastCoreErrorBySessionRef.current = {};
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
setActivityLabel(null);
|
||||
setHydratedHistorySessionId(null);
|
||||
setPendingToolApprovals([]);
|
||||
setPendingAskQuestions([]);
|
||||
@@ -2906,6 +2999,7 @@ export function useChatSession() {
|
||||
activeSessionIdRef.current = session.sessionId;
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
setActivityLabel(null);
|
||||
// A freshly hydrated session has no local turn in flight; without
|
||||
// this the mount defaults (epoch 0, settled -1) read as an open
|
||||
// turn and keep the stale-stream fallback inert forever.
|
||||
@@ -3178,6 +3272,7 @@ export function useChatSession() {
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
activityLabel,
|
||||
config,
|
||||
messages,
|
||||
rawTranscript,
|
||||
|
||||
@@ -549,3 +549,371 @@ describe("useSessionHistory complete history loading", () => {
|
||||
expect(current.mayHaveMoreSessions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory usage hydration", () => {
|
||||
// Distinct timestamps so the sorted list is session-0, session-1, ... and
|
||||
// index-based assertions read naturally.
|
||||
function usageRow(index: number) {
|
||||
const startedAt = new Date(
|
||||
Date.UTC(2026, 6, 20, 10, 0, 0) - index * 60_000,
|
||||
);
|
||||
return {
|
||||
...sessionRow(`session-${index}`),
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: new Date(startedAt.getTime() + 30_000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function usageMessages(inputTokens: number) {
|
||||
return [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
meta: { inputTokens, outputTokens: 5, totalCost: 0.01 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type ReadArgs = { limit?: number; sessionId?: string; maxMessages?: number };
|
||||
|
||||
/**
|
||||
* Routes the usage reads (1200 messages) to the test. The 80-message
|
||||
* title/status reads get a real assistant turn back so they do not flip
|
||||
* statuses to "idle" and retrigger usage reads for those rows.
|
||||
*/
|
||||
function mockUsageReads(
|
||||
onUsageRead: (sessionId: string) => unknown[] | Promise<unknown[]>,
|
||||
) {
|
||||
invokeMock.mockImplementation(async (command: string, args?: ReadArgs) => {
|
||||
if (command === "list_discovered_sessions") {
|
||||
return await new Promise<unknown[]>((resolve, reject) => {
|
||||
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
|
||||
});
|
||||
}
|
||||
if (command === "read_session_messages") {
|
||||
if (args?.maxMessages === 1200) {
|
||||
return await onUsageRead(args?.sessionId ?? "");
|
||||
}
|
||||
return usageMessages(0);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Lets the invoke → summarize → setThreads → finally → pump chain settle. */
|
||||
async function settle() {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
|
||||
async function renderWithRows(count: number) {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(
|
||||
Array.from({ length: count }, (_, index) => usageRow(index)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("hydrates usage for the first ten inactive sessions, not just four", async () => {
|
||||
const usageReads: string[] = [];
|
||||
mockUsageReads((sessionId) => {
|
||||
usageReads.push(sessionId);
|
||||
return usageMessages(100);
|
||||
});
|
||||
|
||||
await renderWithRows(12);
|
||||
expect(current.threads.map((thread) => thread.id)).toEqual(
|
||||
Array.from({ length: 12 }, (_, index) => `session-${index}`),
|
||||
);
|
||||
|
||||
await flush(800);
|
||||
await settle();
|
||||
|
||||
expect(usageReads).toHaveLength(10);
|
||||
expect(usageReads).not.toContain("session-10");
|
||||
expect(usageReads).not.toContain("session-11");
|
||||
expect(current.threads[0]).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 5,
|
||||
totalCostUsd: 0.01,
|
||||
});
|
||||
expect(current.threads[9]).toMatchObject({ inputTokens: 100 });
|
||||
expect(current.threads[10].inputTokens).toBeUndefined();
|
||||
expect(current.threads[11].inputTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hydrates usage on demand for sessions a view asks for", async () => {
|
||||
const usageReads: string[] = [];
|
||||
mockUsageReads((sessionId) => {
|
||||
usageReads.push(sessionId);
|
||||
return usageMessages(7);
|
||||
});
|
||||
|
||||
await renderWithRows(12);
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(usageReads).toHaveLength(10);
|
||||
|
||||
// The second page comes into view: only the rows it asks for are read.
|
||||
await act(async () => {
|
||||
current.requestUsage(["session-11", " ", "not-a-session"]);
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
|
||||
expect(usageReads).toHaveLength(11);
|
||||
expect(usageReads).toContain("session-11");
|
||||
expect(usageReads).not.toContain("session-10");
|
||||
expect(current.threads[11]).toMatchObject({ inputTokens: 7 });
|
||||
expect(current.threads[10].inputTokens).toBeUndefined();
|
||||
|
||||
// Asking again for rows that already have usage is a no-op.
|
||||
await act(async () => {
|
||||
current.requestUsage(["session-0", "session-11"]);
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(usageReads).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("reads at most four transcripts at a time", async () => {
|
||||
const pendingReads: Array<(rows: unknown[]) => void> = [];
|
||||
mockUsageReads(
|
||||
() =>
|
||||
new Promise<unknown[]>((resolve) => {
|
||||
pendingReads.push(resolve);
|
||||
}),
|
||||
);
|
||||
|
||||
await renderWithRows(12);
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(pendingReads).toHaveLength(4);
|
||||
|
||||
// Finishing one read lets exactly one more start.
|
||||
await act(async () => {
|
||||
pendingReads[0](usageMessages(1));
|
||||
});
|
||||
await settle();
|
||||
expect(pendingReads).toHaveLength(5);
|
||||
|
||||
// Draining the rest works through the whole window and no further.
|
||||
let resolved = 1;
|
||||
for (
|
||||
let round = 0;
|
||||
round < 6 && resolved < pendingReads.length;
|
||||
round += 1
|
||||
) {
|
||||
const batch = pendingReads.slice(resolved);
|
||||
resolved = pendingReads.length;
|
||||
await act(async () => {
|
||||
for (const resolve of batch) {
|
||||
resolve(usageMessages(1));
|
||||
}
|
||||
});
|
||||
await settle();
|
||||
}
|
||||
expect(pendingReads).toHaveLength(10);
|
||||
expect(
|
||||
current.threads.filter((thread) => thread.inputTokens === 1),
|
||||
).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("keeps the four-read cap when the effect restarts mid-flight", async () => {
|
||||
const pendingReads: Array<(rows: unknown[]) => void> = [];
|
||||
const readIds: string[] = [];
|
||||
mockUsageReads((sessionId) => {
|
||||
readIds.push(sessionId);
|
||||
return new Promise<unknown[]>((resolve) => {
|
||||
pendingReads.push(resolve);
|
||||
});
|
||||
});
|
||||
|
||||
await renderWithRows(12);
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(pendingReads).toHaveLength(4);
|
||||
|
||||
// A page request restarts the effect while four reads are pending. The
|
||||
// restarted run must not add four reads of its own on top of them.
|
||||
await act(async () => {
|
||||
current.requestUsage(["session-11"]);
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(pendingReads).toHaveLength(4);
|
||||
|
||||
// The restarted run still drains as the earlier reads finish.
|
||||
await act(async () => {
|
||||
pendingReads[0](usageMessages(1));
|
||||
});
|
||||
await settle();
|
||||
expect(pendingReads).toHaveLength(5);
|
||||
|
||||
let resolved = 1;
|
||||
for (
|
||||
let round = 0;
|
||||
round < 8 && resolved < pendingReads.length;
|
||||
round += 1
|
||||
) {
|
||||
const batch = pendingReads.slice(resolved);
|
||||
resolved = pendingReads.length;
|
||||
await act(async () => {
|
||||
for (const resolve of batch) {
|
||||
resolve(usageMessages(1));
|
||||
}
|
||||
});
|
||||
await settle();
|
||||
}
|
||||
// Ten default rows plus the requested one, each read exactly once; the
|
||||
// rows the restarted run found in flight were not read again once they
|
||||
// finished, and session-10 was never asked for.
|
||||
expect(pendingReads).toHaveLength(11);
|
||||
expect(new Set(readIds).size).toBe(readIds.length);
|
||||
expect(readIds).not.toContain("session-10");
|
||||
expect(current.threads[10].inputTokens).toBeUndefined();
|
||||
expect(current.threads[11]).toMatchObject({ inputTokens: 1 });
|
||||
});
|
||||
|
||||
it("reads a row again when its status changed while its read was pending", async () => {
|
||||
const pendingReads: Array<(rows: unknown[]) => void> = [];
|
||||
const readIds: string[] = [];
|
||||
mockUsageReads((sessionId) => {
|
||||
readIds.push(sessionId);
|
||||
return new Promise<unknown[]>((resolve) => {
|
||||
pendingReads.push(resolve);
|
||||
});
|
||||
});
|
||||
const rows = Array.from({ length: 12 }, (_, index) => usageRow(index));
|
||||
const running = { ...rows[3], status: "running", prompt: "long task" };
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(
|
||||
rows.map((row, index) => (index === 3 ? running : row)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.threads[3].status).toBe("running");
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(readIds).toEqual([
|
||||
"session-0",
|
||||
"session-1",
|
||||
"session-2",
|
||||
"session-3",
|
||||
]);
|
||||
|
||||
// The periodic poll reports session-3 finished while its read (started
|
||||
// under "running") is still pending.
|
||||
await flush(12_000);
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve(
|
||||
rows.map((row, index) =>
|
||||
index === 3 ? { ...running, status: "completed" } : row,
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(current.threads[3].status).toBe("completed");
|
||||
await flush(800);
|
||||
await settle();
|
||||
// Still four in flight: the restarted run neither stacks a second read
|
||||
// of session-3 on the pending one nor forgets the row.
|
||||
expect(pendingReads).toHaveLength(4);
|
||||
|
||||
// The stale read finishes: session-3 is read again, once, before the
|
||||
// rows that have not been read at all.
|
||||
await act(async () => {
|
||||
pendingReads[3](usageMessages(1));
|
||||
});
|
||||
await settle();
|
||||
expect(readIds.slice(4)).toEqual(["session-3"]);
|
||||
expect(pendingReads).toHaveLength(5);
|
||||
|
||||
await act(async () => {
|
||||
pendingReads[4](usageMessages(2));
|
||||
});
|
||||
await settle();
|
||||
expect(current.threads[3]).toMatchObject({ inputTokens: 2 });
|
||||
});
|
||||
|
||||
it("stops re-reading a running row once the view no longer asks for it", async () => {
|
||||
const readIds: string[] = [];
|
||||
mockUsageReads((sessionId) => {
|
||||
readIds.push(sessionId);
|
||||
return usageMessages(3);
|
||||
});
|
||||
const rows = Array.from({ length: 12 }, (_, index) => usageRow(index));
|
||||
const running = { ...rows[11], status: "running", prompt: "still going" };
|
||||
// Each refresh changes session-0's prompt so the list is not equivalent
|
||||
// to the previous one and the hydration effect restarts.
|
||||
const listRows = (marker: string) =>
|
||||
rows.map((row, index) => {
|
||||
if (index === 11) return running;
|
||||
if (index === 0) return { ...row, prompt: marker };
|
||||
return row;
|
||||
});
|
||||
const readsOfRunning = () =>
|
||||
readIds.filter((sessionId) => sessionId === "session-11").length;
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(listRows("first"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(readIds).toHaveLength(10);
|
||||
expect(readsOfRunning()).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
current.requestUsage(["session-11"]);
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(readsOfRunning()).toBe(1);
|
||||
|
||||
// While a view shows the running row, a refresh re-reads it.
|
||||
await flush(12_000);
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve(listRows("second"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(readsOfRunning()).toBe(2);
|
||||
|
||||
// The view pages away or unmounts: the next refresh leaves it alone,
|
||||
// and the completed rows it already hydrated are not read again either.
|
||||
await act(async () => {
|
||||
current.requestUsage([]);
|
||||
});
|
||||
await flush(12_000);
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[2].resolve(listRows("third"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flush(800);
|
||||
await settle();
|
||||
expect(readsOfRunning()).toBe(2);
|
||||
expect(readIds).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,12 @@ type SessionMessage = {
|
||||
meta?: SessionMessageMeta;
|
||||
};
|
||||
|
||||
type SessionUsage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostUsd: number;
|
||||
};
|
||||
|
||||
type SessionTitleUpdatedEvent = CustomEvent<{
|
||||
sessionId: string;
|
||||
title: string;
|
||||
@@ -113,6 +119,15 @@ export type UseSessionHistoryOptions = {
|
||||
// pages 10 at a time, so the mount fetch (and every 12s poll after it) only
|
||||
// needs enough rows for the first few pages. Older pages are fetched on demand.
|
||||
const INITIAL_HISTORY_FETCH_LIMIT = 50;
|
||||
// Discovery rows carry no token or cost totals; those are summed from each
|
||||
// session's transcript in a second round trip. Only the rows that are on
|
||||
// screen get that read: the first page of the sessions view (and the
|
||||
// sidebar's first threads) by default, plus whatever page a view asks for
|
||||
// via requestUsage as the user moves through older sessions.
|
||||
const USAGE_HYDRATION_WINDOW = 10;
|
||||
// Each usage read parses a whole transcript in the sidecar, so a page of rows
|
||||
// is drained a few at a time instead of all at once.
|
||||
const MAX_CONCURRENT_USAGE_FETCHES = 4;
|
||||
const HISTORY_REFRESH_INTERVAL_MS = 12_000;
|
||||
const MIN_EVENT_HISTORY_REFRESH_INTERVAL_MS = 2_000;
|
||||
const HISTORY_EVENT_REFRESH_DELAY_MS = 1_000;
|
||||
@@ -541,6 +556,14 @@ export function useSessionHistory({
|
||||
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Rows a view currently has on screen beyond the default window (the
|
||||
// sessions view reports its visible page and clears it on unmount). Each
|
||||
// call replaces the previous set rather than adding to it, so the set is
|
||||
// bounded by one page and a running session the user has paged away from
|
||||
// is not re-read on every refresh.
|
||||
const [requestedUsageIds, setRequestedUsageIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// Sessions that schedule executions report as their own, keyed by session
|
||||
// id. Scheduled runs executed by the local hub do not reliably carry the
|
||||
// "hub-schedule" origin trigger in their session metadata (the runtime
|
||||
@@ -557,7 +580,19 @@ export function useSessionHistory({
|
||||
// may itself name a batch that was never fetched.
|
||||
const loadedLimitRef = useRef(0);
|
||||
const mayHaveMoreSessionsRef = useRef(false);
|
||||
const usageLoadingRef = useRef<Set<string>>(new Set());
|
||||
// Every usage read in flight, across effect runs, with the status the read
|
||||
// was started under. Its size is the gauge the concurrency cap is enforced
|
||||
// on; the status tells a restarted run whether the pending read already
|
||||
// covers the row's current status or the row must be read again after it.
|
||||
const usageLoadingRef = useRef<Map<string, SessionHistoryStatus>>(new Map());
|
||||
// Queue drainer of the current hydration run. Finished reads call it so a
|
||||
// freed slot goes to the newest run, not to the run that started the read.
|
||||
const usagePumpRef = useRef<(() => void) | null>(null);
|
||||
// Usage each row was last hydrated with, written the moment a read settles.
|
||||
// threadsRef only catches up after React commits, so the queue consults
|
||||
// this to tell "hydrated" from "not yet" without a stale window in between.
|
||||
// Refreshes also rebuild threads from it, so a row keeps its totals.
|
||||
const usageByIdRef = useRef<Map<string, SessionUsage>>(new Map());
|
||||
const usageHydratedStatusRef = useRef<Map<string, SessionHistoryStatus>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -762,16 +797,6 @@ export function useSessionHistory({
|
||||
const existingById = new Map(
|
||||
current.map((thread) => [thread.id, thread]),
|
||||
);
|
||||
const usageById = new Map(
|
||||
current.map((thread) => [
|
||||
thread.id,
|
||||
{
|
||||
inputTokens: thread.inputTokens,
|
||||
outputTokens: thread.outputTokens,
|
||||
totalCostUsd: thread.totalCostUsd,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const next = mapped.map((thread) => {
|
||||
const existing = existingById.get(thread.id);
|
||||
const incomingMetadataTitle = metadataTitleById.get(thread.id);
|
||||
@@ -783,7 +808,7 @@ export function useSessionHistory({
|
||||
...thread,
|
||||
title:
|
||||
keepExistingTitle && existing ? existing.title : thread.title,
|
||||
...usageById.get(thread.id),
|
||||
...usageByIdRef.current.get(thread.id),
|
||||
};
|
||||
});
|
||||
return areThreadsEquivalent(current, next) ? current : next;
|
||||
@@ -882,45 +907,82 @@ export function useSessionHistory({
|
||||
};
|
||||
}, [scheduleRefresh]);
|
||||
|
||||
const requestUsage = useCallback((sessionIds: readonly string[]) => {
|
||||
setRequestedUsageIds((current) => {
|
||||
const next = new Set<string>();
|
||||
for (const raw of sessionIds) {
|
||||
const sessionId = raw?.trim();
|
||||
if (sessionId) {
|
||||
next.add(sessionId);
|
||||
}
|
||||
}
|
||||
// Same members, same instance: the sessions view re-reports its
|
||||
// page on every threads change, and a fresh Set would restart the
|
||||
// hydration effect (and its 800ms delay) each time a row filled in.
|
||||
if (
|
||||
next.size === current.size &&
|
||||
[...next].every((sessionId) => current.has(sessionId))
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const recent = sessions
|
||||
.filter((session) => session.sessionId !== activeSessionId)
|
||||
.slice(0, 4);
|
||||
// The active session is skipped: its transcript is still being written
|
||||
// and the chat tracks its usage live.
|
||||
const inactiveSessions = sessions.filter(
|
||||
(session) => session.sessionId !== activeSessionId,
|
||||
);
|
||||
const targets = inactiveSessions.slice(0, USAGE_HYDRATION_WINDOW);
|
||||
if (requestedUsageIds.size > 0) {
|
||||
const queued = new Set(targets.map((session) => session.sessionId));
|
||||
for (const session of inactiveSessions) {
|
||||
if (
|
||||
requestedUsageIds.has(session.sessionId) &&
|
||||
!queued.has(session.sessionId)
|
||||
) {
|
||||
targets.push(session);
|
||||
queued.add(session.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
for (const session of recent) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
// "fetch": the row was never hydrated, is running (its totals keep
|
||||
// moving), or was hydrated under a different status.
|
||||
// "defer": a read is in flight, but it was started under a different
|
||||
// status than the row has now, so its result will already be stale;
|
||||
// keep the row queued and read it again once that read finishes.
|
||||
// "skip": nothing to do, drop the row from this run's queue.
|
||||
const usageFetchVerdict = (
|
||||
session: SessionHistoryItem,
|
||||
): "fetch" | "defer" | "skip" => {
|
||||
const sessionId = session.sessionId;
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
return "skip";
|
||||
}
|
||||
if (usageLoadingRef.current.has(sessionId)) {
|
||||
continue;
|
||||
const inFlightStatus = usageLoadingRef.current.get(sessionId);
|
||||
if (inFlightStatus !== undefined) {
|
||||
return inFlightStatus === session.status ? "skip" : "defer";
|
||||
}
|
||||
const existing = threadsRef.current.find(
|
||||
(item) => item.id === sessionId,
|
||||
);
|
||||
const hasUsage =
|
||||
existing?.inputTokens !== undefined ||
|
||||
existing?.outputTokens !== undefined;
|
||||
const lastHydratedStatus =
|
||||
usageHydratedStatusRef.current.get(sessionId);
|
||||
const shouldFetch =
|
||||
!hasUsage ||
|
||||
const needsFetch =
|
||||
!usageByIdRef.current.has(sessionId) ||
|
||||
session.status === "running" ||
|
||||
lastHydratedStatus !== session.status;
|
||||
if (!shouldFetch) {
|
||||
continue;
|
||||
}
|
||||
usageLoadingRef.current.add(sessionId);
|
||||
usageHydratedStatusRef.current.get(sessionId) !== session.status;
|
||||
return needsFetch ? "fetch" : "skip";
|
||||
};
|
||||
|
||||
const startUsageFetch = (session: SessionHistoryItem): void => {
|
||||
const sessionId = session.sessionId;
|
||||
usageLoadingRef.current.set(sessionId, session.status);
|
||||
void desktopClient
|
||||
.invoke<SessionMessage[]>("read_session_messages", {
|
||||
sessionId,
|
||||
maxMessages: 1200,
|
||||
})
|
||||
.then(async (sessionMessages) => {
|
||||
.then(async (sessionMessages): Promise<SessionUsage> => {
|
||||
const usage = summarizeUsageFromMessages(sessionMessages);
|
||||
if (!usage) {
|
||||
const events = await desktopClient.invoke<SessionHookEvent[]>(
|
||||
@@ -947,52 +1009,101 @@ export function useSessionHistory({
|
||||
}
|
||||
return usage;
|
||||
})
|
||||
.then(({ inputTokens, outputTokens, totalCostUsd }) => {
|
||||
.then((usage) => {
|
||||
usageByIdRef.current.set(sessionId, usage);
|
||||
setThreads((current) =>
|
||||
updateThreadById(current, sessionId, (thread) => {
|
||||
if (
|
||||
thread.inputTokens === inputTokens &&
|
||||
thread.outputTokens === outputTokens &&
|
||||
thread.totalCostUsd === totalCostUsd
|
||||
thread.inputTokens === usage.inputTokens &&
|
||||
thread.outputTokens === usage.outputTokens &&
|
||||
thread.totalCostUsd === usage.totalCostUsd
|
||||
) {
|
||||
return thread;
|
||||
}
|
||||
return { ...thread, inputTokens, outputTokens, totalCostUsd };
|
||||
return { ...thread, ...usage };
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!hasUsage) {
|
||||
setThreads((current) =>
|
||||
updateThreadById(current, sessionId, (thread) => {
|
||||
if (
|
||||
thread.inputTokens === 0 &&
|
||||
thread.outputTokens === 0 &&
|
||||
(thread.totalCostUsd ?? 0) === 0
|
||||
) {
|
||||
return thread;
|
||||
}
|
||||
return {
|
||||
...thread,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
// A failed read on a row that was never hydrated is recorded
|
||||
// as zero usage so the row is not retried on every pass; a
|
||||
// later status change reads it again. A row that already has
|
||||
// totals keeps them.
|
||||
if (usageByIdRef.current.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
const usage: SessionUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
};
|
||||
usageByIdRef.current.set(sessionId, usage);
|
||||
setThreads((current) =>
|
||||
updateThreadById(current, sessionId, (thread) => {
|
||||
if (
|
||||
thread.inputTokens === 0 &&
|
||||
thread.outputTokens === 0 &&
|
||||
(thread.totalCostUsd ?? 0) === 0
|
||||
) {
|
||||
return thread;
|
||||
}
|
||||
return { ...thread, ...usage };
|
||||
}),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
// Record the status the read was started under, not the
|
||||
// row's current one: if the status moved while the read was
|
||||
// pending, the mismatch is what makes the row read again.
|
||||
usageHydratedStatusRef.current.set(sessionId, session.status);
|
||||
usageLoadingRef.current.delete(sessionId);
|
||||
// Hand the freed slot to whichever run is current: this
|
||||
// run may have been replaced while the read was pending.
|
||||
usagePumpRef.current?.();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The cap is checked against usageLoadingRef, which counts reads in
|
||||
// flight across effect runs, not against a per-run counter. A refresh
|
||||
// or page change restarts this effect while reads are still pending;
|
||||
// a per-run counter would start at zero and let the new run add four
|
||||
// more on top of them. Each pass walks the whole queue: rows that
|
||||
// need nothing are dropped, rows waiting on a pending read that will
|
||||
// be stale are kept for the next pass, and the rest start as slots
|
||||
// allow, in order.
|
||||
const queue = [...targets];
|
||||
const pump = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const remaining: SessionHistoryItem[] = [];
|
||||
for (const session of queue) {
|
||||
const verdict = usageFetchVerdict(session);
|
||||
if (verdict === "skip") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
verdict === "defer" ||
|
||||
usageLoadingRef.current.size >= MAX_CONCURRENT_USAGE_FETCHES
|
||||
) {
|
||||
remaining.push(session);
|
||||
continue;
|
||||
}
|
||||
startUsageFetch(session);
|
||||
}
|
||||
queue.splice(0, queue.length, ...remaining);
|
||||
};
|
||||
usagePumpRef.current = pump;
|
||||
pump();
|
||||
}, 800);
|
||||
return () => {
|
||||
// Rows still queued are picked up again by the next run; reads
|
||||
// already in flight finish, land on their threads, and pump the run
|
||||
// that is current by then.
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeSessionId, sessions]);
|
||||
}, [activeSessionId, requestedUsageIds, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTitleUpdated = (event: Event) => {
|
||||
@@ -1025,9 +1136,12 @@ export function useSessionHistory({
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
usageLoadingRef.current.delete(sessionId);
|
||||
// usageLoadingRef is left to the pending read's own finally: it is
|
||||
// the in-flight gauge for the read cap, so freeing the slot here would
|
||||
// let a fifth read start while the deleted row's read is still running.
|
||||
titleLoadingRef.current.delete(sessionId);
|
||||
usageHydratedStatusRef.current.delete(sessionId);
|
||||
usageByIdRef.current.delete(sessionId);
|
||||
messageHydratedStatusRef.current.delete(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
@@ -1617,6 +1731,7 @@ export function useSessionHistory({
|
||||
pendingAction,
|
||||
refreshSessions,
|
||||
renameThread,
|
||||
requestUsage,
|
||||
setThreadPinned,
|
||||
deleteThread,
|
||||
forkThread,
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("@/lib/desktop-client", () => ({
|
||||
import {
|
||||
APP_ICON_STORAGE_KEY,
|
||||
appIconAssetPath,
|
||||
appIconSurface,
|
||||
DEFAULT_APP_ICON,
|
||||
isAppIconId,
|
||||
readStoredAppIcon,
|
||||
@@ -44,6 +45,14 @@ describe("app icon", () => {
|
||||
expect(isAppIconId("bogus")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Taskbar"],
|
||||
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "Dock"],
|
||||
["Mozilla/5.0 (X11; Linux x86_64)", "desktop"],
|
||||
])("names the app icon surface for %s", (userAgent, surface) => {
|
||||
expect(appIconSurface(userAgent)).toBe(surface);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["sunrise", "hologram"],
|
||||
["steel", "midnight"],
|
||||
@@ -72,6 +81,24 @@ describe("app icon", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("set_app_icon", { icon: "midnight" });
|
||||
});
|
||||
|
||||
it("does not persist a native selection that fails to apply", async () => {
|
||||
isTauriAvailable.mockReturnValue(true);
|
||||
invoke.mockRejectedValue(new Error("native update failed"));
|
||||
|
||||
await expect(setStoredAppIcon("classic")).rejects.toThrow(
|
||||
"native update failed",
|
||||
);
|
||||
expect(window.localStorage.getItem(APP_ICON_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the favicon unchanged when native application fails", async () => {
|
||||
isTauriAvailable.mockReturnValue(true);
|
||||
invoke.mockRejectedValue(new Error("native update failed"));
|
||||
|
||||
await expect(setStoredAppIcon("classic")).rejects.toThrow();
|
||||
expect(document.querySelector('link[rel="icon"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("re-applies only non-bundled choices at boot", async () => {
|
||||
isTauriAvailable.mockReturnValue(true);
|
||||
invoke.mockResolvedValue(true);
|
||||
|
||||
@@ -5,7 +5,7 @@ export const APP_ICON_STORAGE_KEY = "cline.code.app-icon.v1";
|
||||
/**
|
||||
* App icon variants selectable in Settings. "midnight" is the icon bundled
|
||||
* with the app; the others live in webview/public/app-icons (picker +
|
||||
* browser favicon) and src-tauri/icons/dock (runtime dock icon resources).
|
||||
* browser favicon) and src-tauri/icons/app (runtime app icon resources).
|
||||
*/
|
||||
export const APP_ICONS = [
|
||||
{ id: "classic", label: "Classic" },
|
||||
@@ -40,6 +40,14 @@ export function appIconAssetPath(icon: AppIconId): string {
|
||||
return `/app-icons/${icon}.png`;
|
||||
}
|
||||
|
||||
export function appIconSurface(
|
||||
userAgent: string,
|
||||
): "Dock" | "Taskbar" | "desktop" {
|
||||
if (/Windows/i.test(userAgent)) return "Taskbar";
|
||||
if (/(Macintosh|Mac OS X)/i.test(userAgent)) return "Dock";
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
export function readStoredAppIcon(): AppIconId {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(APP_ICON_STORAGE_KEY);
|
||||
@@ -65,30 +73,29 @@ function applyFavicon(icon: AppIconId): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the icon to whatever this runtime can control: the macOS dock
|
||||
* Applies the icon to whatever this runtime can control: the Dock or taskbar
|
||||
* icon in the Tauri shell (native `set_app_icon` command, best-effort) and
|
||||
* the favicon in browser dev mode so the choice is still visible there.
|
||||
*/
|
||||
export async function applyAppIcon(icon: AppIconId): Promise<void> {
|
||||
applyFavicon(icon);
|
||||
if (!isTauriAvailable()) {
|
||||
return;
|
||||
if (isTauriAvailable()) {
|
||||
await desktopClient.invoke("set_app_icon", { icon });
|
||||
}
|
||||
await desktopClient.invoke("set_app_icon", { icon });
|
||||
applyFavicon(icon);
|
||||
}
|
||||
|
||||
export async function setStoredAppIcon(icon: AppIconId): Promise<void> {
|
||||
await applyAppIcon(icon);
|
||||
try {
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, icon);
|
||||
} catch {
|
||||
// Selection falls back to default next launch; applying still works.
|
||||
}
|
||||
await applyAppIcon(icon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies the persisted choice on launch. The dock reverts to the
|
||||
* bundled icon on every restart, so the app shell calls this once at boot.
|
||||
* Re-applies the persisted choice on launch. The native app icon reverts to
|
||||
* the bundled icon on every restart, so the app shell calls this once at boot.
|
||||
*/
|
||||
export async function syncAppIcon(): Promise<void> {
|
||||
const icon = readStoredAppIcon();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readImportedFromTool,
|
||||
readImportedHistorySummaryActivity,
|
||||
} from "./session-import";
|
||||
|
||||
describe("readImportedFromTool", () => {
|
||||
it("reads a known tool off the importedFrom marker", () => {
|
||||
expect(
|
||||
readImportedFromTool({ importedFrom: { tool: "codex", sourceId: "x" } }),
|
||||
).toBe("codex");
|
||||
});
|
||||
|
||||
it("ignores missing, malformed, or unknown markers", () => {
|
||||
expect(readImportedFromTool(undefined)).toBeUndefined();
|
||||
expect(readImportedFromTool({ title: "native" })).toBeUndefined();
|
||||
expect(readImportedFromTool({ importedFrom: "codex" })).toBeUndefined();
|
||||
expect(
|
||||
readImportedFromTool({ importedFrom: { tool: "cursor" } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readImportedHistorySummaryActivity", () => {
|
||||
it("labels the started notice and clears on completion", () => {
|
||||
expect(
|
||||
readImportedHistorySummaryActivity({
|
||||
kind: "manual_compaction",
|
||||
phase: "started",
|
||||
importedFrom: "claude-code",
|
||||
}),
|
||||
).toEqual({
|
||||
phase: "started",
|
||||
label: "Summarizing the imported Claude Code history...",
|
||||
});
|
||||
expect(
|
||||
readImportedHistorySummaryActivity({
|
||||
kind: "manual_compaction",
|
||||
phase: "completed",
|
||||
importedFrom: "claude-code",
|
||||
}),
|
||||
).toEqual({ phase: "finished" });
|
||||
});
|
||||
|
||||
it("ignores compactions that are not imported-history summaries", () => {
|
||||
expect(
|
||||
readImportedHistorySummaryActivity({
|
||||
kind: "auto_compaction",
|
||||
phase: "started",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(readImportedHistorySummaryActivity(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -57,3 +57,58 @@ export interface SessionImportProgressEvent {
|
||||
export function importSelectionKey(tool: string, sourceId: string): string {
|
||||
return `${tool}:${sourceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The external tool a session was imported from, read off the
|
||||
* `metadata.importedFrom` marker the core import service writes. Forks
|
||||
* inherit the source session's metadata, so a fork of an imported session
|
||||
* reports the same tool: its history is still the foreign transcript.
|
||||
*/
|
||||
export function readImportedFromTool(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): SessionImportTool | undefined {
|
||||
const value = metadata?.importedFrom;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return asSessionImportTool((value as { tool?: unknown }).tool);
|
||||
}
|
||||
|
||||
function asSessionImportTool(value: unknown): SessionImportTool | undefined {
|
||||
return typeof value === "string" &&
|
||||
(SESSION_IMPORT_TOOL_ORDER as string[]).includes(value)
|
||||
? (value as SessionImportTool)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export type ImportedHistorySummaryActivity =
|
||||
| { phase: "started"; label: string }
|
||||
| { phase: "finished" };
|
||||
|
||||
/**
|
||||
* Reads the compaction status notice core emits while it summarizes an
|
||||
* imported session's history on the first resumed turn (core tags those
|
||||
* notices with `importedFrom`). The label stands in for the generic
|
||||
* "Thinking..." indicator while the summary runs; `finished` clears it.
|
||||
* Other notices return undefined.
|
||||
*/
|
||||
export function readImportedHistorySummaryActivity(
|
||||
metadata: unknown,
|
||||
): ImportedHistorySummaryActivity | undefined {
|
||||
if (!metadata || typeof metadata !== "object") return undefined;
|
||||
const record = metadata as Record<string, unknown>;
|
||||
const tool = asSessionImportTool(record.importedFrom);
|
||||
if (!tool) return undefined;
|
||||
switch (record.phase) {
|
||||
case "started":
|
||||
return {
|
||||
phase: "started",
|
||||
label: `Summarizing the imported ${SESSION_IMPORT_TOOL_LABELS[tool]} history...`,
|
||||
};
|
||||
case "completed":
|
||||
case "skipped":
|
||||
return { phase: "finished" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,9 @@ import { asyncIteratorToCallbacks } from "@/standalone/utils"
|
||||
import * as niceGrpc from "@generated/nice-grpc/index"
|
||||
import { StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { Channel, createClient } from "nice-grpc"
|
||||
import { Channel } from "nice-grpc"
|
||||
import { BaseGrpcClient } from "@/hosts/external/grpc-types"
|
||||
import { createHostBridgeClient } from "@/hosts/external/host-bridge-auth"
|
||||
|
||||
${imports.join("\n")}
|
||||
|
||||
@@ -178,7 +179,7 @@ export class ${serviceName}ClientImpl
|
||||
implements ${serviceName}ClientInterface {
|
||||
|
||||
protected createClient(channel: Channel): niceGrpc.host.${serviceName}Client {
|
||||
return createClient(niceGrpc.host.${serviceName}Definition, channel)
|
||||
return createHostBridgeClient(niceGrpc.host.${serviceName}Definition, channel)
|
||||
}
|
||||
|
||||
${methods}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, it } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import { Metadata } from "nice-grpc"
|
||||
import {
|
||||
captureHostBridgeTokenFromEnvironment,
|
||||
HOST_BRIDGE_TOKEN_HEADER,
|
||||
hostBridgeAuthMiddleware,
|
||||
hostBridgeGrpcMetadata,
|
||||
} from "../host-bridge-auth"
|
||||
|
||||
const originalToken = process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
|
||||
/**
|
||||
* Runs the same bootstrap step a spawned core does: the host puts the token in
|
||||
* the environment, startup captures it and scrubs the environment. Tests that
|
||||
* bypassed this and read env directly missed that the header must come from
|
||||
* the retained copy — after bootstrap the variable is gone.
|
||||
*/
|
||||
function setToken(token: string | undefined) {
|
||||
if (token === undefined) {
|
||||
delete process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
} else {
|
||||
process.env.CLINE_CORE_CONNECTION_TOKEN = token
|
||||
}
|
||||
captureHostBridgeTokenFromEnvironment()
|
||||
}
|
||||
|
||||
/** Drives the middleware and returns the options it passed downstream. */
|
||||
async function runMiddleware(options: Record<string, unknown>): Promise<any> {
|
||||
let forwarded: any
|
||||
const call: any = {
|
||||
request: { some: "request" },
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
next: async function* (_request: unknown, nextOptions: unknown) {
|
||||
forwarded = nextOptions
|
||||
return "response"
|
||||
},
|
||||
}
|
||||
const iterator = (hostBridgeAuthMiddleware as any)(call, options)
|
||||
await iterator.next()
|
||||
return forwarded
|
||||
}
|
||||
|
||||
describe("host bridge auth", () => {
|
||||
afterEach(() => setToken(originalToken))
|
||||
|
||||
it("attaches the spawn token to outgoing calls", async () => {
|
||||
setToken("spawn-token-1")
|
||||
|
||||
const forwarded = await runMiddleware({ metadata: Metadata() })
|
||||
|
||||
expect(forwarded.metadata.get(HOST_BRIDGE_TOKEN_HEADER)).to.equal("spawn-token-1")
|
||||
})
|
||||
|
||||
it("preserves caller-supplied metadata", async () => {
|
||||
setToken("spawn-token-1")
|
||||
|
||||
const forwarded = await runMiddleware({ metadata: Metadata({ "x-existing": "kept" }) })
|
||||
|
||||
expect(forwarded.metadata.get("x-existing")).to.equal("kept")
|
||||
expect(forwarded.metadata.get(HOST_BRIDGE_TOKEN_HEADER)).to.equal("spawn-token-1")
|
||||
})
|
||||
|
||||
it("sends no header when the core was spawned without a token", async () => {
|
||||
setToken(undefined)
|
||||
|
||||
const forwarded = await runMiddleware({ metadata: Metadata() })
|
||||
|
||||
expect(forwarded.metadata.has(HOST_BRIDGE_TOKEN_HEADER)).to.equal(false)
|
||||
})
|
||||
|
||||
it("keeps sending the token after bootstrap scrubbed it from the environment", async () => {
|
||||
setToken("spawn-token-1")
|
||||
expect(process.env.CLINE_CORE_CONNECTION_TOKEN).to.equal(undefined)
|
||||
|
||||
const forwarded = await runMiddleware({ metadata: Metadata() })
|
||||
|
||||
expect(forwarded.metadata.get(HOST_BRIDGE_TOKEN_HEADER)).to.equal("spawn-token-1")
|
||||
})
|
||||
|
||||
describe("capture", () => {
|
||||
it("returns the token and removes it from the environment", () => {
|
||||
process.env.CLINE_CORE_CONNECTION_TOKEN = "spawn-token-3"
|
||||
|
||||
const captured = captureHostBridgeTokenFromEnvironment()
|
||||
|
||||
expect(captured).to.equal("spawn-token-3")
|
||||
expect(process.env.CLINE_CORE_CONNECTION_TOKEN).to.equal(undefined)
|
||||
})
|
||||
|
||||
it("treats an empty variable as no token", () => {
|
||||
process.env.CLINE_CORE_CONNECTION_TOKEN = ""
|
||||
|
||||
expect(captureHostBridgeTokenFromEnvironment()).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("grpc-js metadata", () => {
|
||||
it("carries the token for hand-written clients", () => {
|
||||
setToken("spawn-token-2")
|
||||
|
||||
expect(hostBridgeGrpcMetadata().get(HOST_BRIDGE_TOKEN_HEADER)).to.deep.equal(["spawn-token-2"])
|
||||
})
|
||||
|
||||
it("is empty without a token", () => {
|
||||
setToken(undefined)
|
||||
|
||||
expect(hostBridgeGrpcMetadata().get(HOST_BRIDGE_TOKEN_HEADER)).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import { EnvServiceClientImpl } from "@generated/hosts/standalone/host-bridge-clients"
|
||||
import * as niceGrpc from "@generated/nice-grpc/index"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { expect } from "chai"
|
||||
import { createServer, type Server } from "nice-grpc"
|
||||
import { captureHostBridgeTokenFromEnvironment, HOST_BRIDGE_TOKEN_HEADER } from "../host-bridge-auth"
|
||||
|
||||
type SeenCall = { method: string; token: string | undefined }
|
||||
|
||||
/**
|
||||
* End-to-end cover for the wiring the middleware unit tests cannot see: the
|
||||
* generator has to emit `createHostBridgeClient` into every generated client,
|
||||
* and that factory has to put the token on the wire. Asserting against a real
|
||||
* server means a regression in the generator template — or in the factory —
|
||||
* fails here instead of silently shipping unauthenticated calls.
|
||||
*
|
||||
* Tokens enter the way they do in production — set in the environment by the
|
||||
* host, then captured and scrubbed by the same bootstrap function cline-core
|
||||
* runs — so this also covers the startup-to-receiver path, not just the
|
||||
* middleware in isolation.
|
||||
*
|
||||
* EnvService stands in for all of them: the clients are generated from one
|
||||
* template, so the wiring is identical per service.
|
||||
*/
|
||||
describe("generated host bridge clients (end to end)", () => {
|
||||
const originalToken = process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
let server: Server
|
||||
let address: string
|
||||
let seen: SeenCall[]
|
||||
|
||||
beforeEach(async () => {
|
||||
seen = []
|
||||
server = createServer()
|
||||
server.add(
|
||||
niceGrpc.host.EnvServiceDefinition,
|
||||
recordingEnvService((call) => seen.push(call)),
|
||||
)
|
||||
const port = await server.listen("127.0.0.1:0")
|
||||
address = `127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setToken(originalToken)
|
||||
await server.forceShutdown()
|
||||
})
|
||||
|
||||
it("sends the spawn token on unary calls, after bootstrap scrubbed it from the environment", async () => {
|
||||
setToken("e2e-token")
|
||||
// The startup-to-receiver path: by the time any bridge call is made the
|
||||
// variable is gone, so the header can only come from the retained copy.
|
||||
expect(process.env.CLINE_CORE_CONNECTION_TOKEN).to.equal(undefined)
|
||||
|
||||
await new EnvServiceClientImpl(address).getHostVersion(proto.cline.EmptyRequest.create({}))
|
||||
|
||||
expect(seen).to.deep.equal([{ method: "getHostVersion", token: "e2e-token" }])
|
||||
})
|
||||
|
||||
it("sends the spawn token on streaming calls", async () => {
|
||||
// Streaming calls pass their own call options (an abort signal), so this
|
||||
// also pins that the metadata survives that merge.
|
||||
setToken("e2e-token")
|
||||
const client = new EnvServiceClientImpl(address)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.subscribeToTelemetrySettings(proto.cline.EmptyRequest.create({}), {
|
||||
onResponse: () => resolve(),
|
||||
onError: reject,
|
||||
})
|
||||
})
|
||||
|
||||
expect(seen[0]).to.deep.equal({ method: "subscribeToTelemetrySettings", token: "e2e-token" })
|
||||
})
|
||||
|
||||
it("omits the header when the core was spawned without a token", async () => {
|
||||
setToken(undefined)
|
||||
|
||||
await new EnvServiceClientImpl(address).getHostVersion(proto.cline.EmptyRequest.create({}))
|
||||
|
||||
expect(seen).to.deep.equal([{ method: "getHostVersion", token: undefined }])
|
||||
})
|
||||
})
|
||||
|
||||
/** The bootstrap step a spawned core runs: capture the host's token, scrub the environment. */
|
||||
function setToken(token: string | undefined) {
|
||||
if (token === undefined) {
|
||||
delete process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
} else {
|
||||
process.env.CLINE_CORE_CONNECTION_TOKEN = token
|
||||
}
|
||||
captureHostBridgeTokenFromEnvironment()
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements every EnvService method from the service definition, recording the
|
||||
* token each call carried. Built from the definition rather than hand-listed so
|
||||
* new RPCs do not break this test.
|
||||
*/
|
||||
function recordingEnvService(record: (call: SeenCall) => void) {
|
||||
const implementation: Record<string, unknown> = {}
|
||||
for (const [method, definition] of Object.entries(niceGrpc.host.EnvServiceDefinition.methods)) {
|
||||
const observe = (context: { metadata: { get(key: string): string | undefined } }) =>
|
||||
record({ method, token: context.metadata.get(HOST_BRIDGE_TOKEN_HEADER) })
|
||||
|
||||
implementation[method] = definition.responseStream
|
||||
? async function* (_request: unknown, context: any) {
|
||||
observe(context)
|
||||
yield {}
|
||||
}
|
||||
: async (_request: unknown, context: any) => {
|
||||
observe(context)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return implementation as any
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
import { type Channel, type ClientMiddleware, type CompatServiceDefinition, createClientFactory, Metadata } from "nice-grpc"
|
||||
|
||||
/**
|
||||
* Request header carrying the per-spawn token that identifies this core to the
|
||||
* host that started it. The name is part of the host<->core contract — keep it
|
||||
* in sync with the host-side interceptor (JetBrains: SessionTokenInterceptor).
|
||||
*/
|
||||
export const HOST_BRIDGE_TOKEN_HEADER = "cline-hostbridge-token"
|
||||
|
||||
/**
|
||||
* The Host Bridge listens on loopback with insecure credentials, so binding
|
||||
* pins *where* it listens but cannot prove *who* is calling: any local process
|
||||
* or OS user can dial the port and drive the IDE. Echoing the token the host
|
||||
* generated for this spawn lets the host tell its own core apart from an
|
||||
* orphaned/foreign one — or from unrelated local code.
|
||||
*
|
||||
* This reuses the token already issued for the core connection stream
|
||||
* (CLINE_CORE_CONNECTION_TOKEN) rather than introducing a second secret, so
|
||||
* there is one credential per spawn with one lifetime.
|
||||
*
|
||||
* The token lives here, in process memory, not in `process.env`: bootstrap
|
||||
* scrubs it from the environment immediately after capture so descendants
|
||||
* (provider or MCP child processes) can never inherit it, and so it is absent
|
||||
* when the environment is logged. Read it via [getHostBridgeToken].
|
||||
*/
|
||||
let hostBridgeToken: string | undefined
|
||||
|
||||
/**
|
||||
* Captures the per-spawn token from the environment and removes it from
|
||||
* `process.env`, retaining it only in process memory for the bridge clients.
|
||||
*
|
||||
* Call once, first thing at startup — before anything logs the environment or
|
||||
* can spawn a child process. Returns the token so bootstrap can also use it for
|
||||
* the core connection hello. A core spawned without a token (standalone dev
|
||||
* runs, older hosts) gets `undefined` and sends no header.
|
||||
*/
|
||||
export function captureHostBridgeTokenFromEnvironment(): string | undefined {
|
||||
hostBridgeToken = process.env.CLINE_CORE_CONNECTION_TOKEN || undefined
|
||||
delete process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
return hostBridgeToken
|
||||
}
|
||||
|
||||
/** The token captured at startup, or `undefined` when this core was spawned without one. */
|
||||
export function getHostBridgeToken(): string | undefined {
|
||||
return hostBridgeToken
|
||||
}
|
||||
|
||||
/** Metadata for hand-written `@grpc/grpc-js` clients (health check, core connection stream). */
|
||||
export function hostBridgeGrpcMetadata(): grpc.Metadata {
|
||||
const metadata = new grpc.Metadata()
|
||||
const token = getHostBridgeToken()
|
||||
if (token) {
|
||||
metadata.set(HOST_BRIDGE_TOKEN_HEADER, token)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
/** Attaches the token to every outgoing call, preserving any caller-supplied metadata. */
|
||||
export const hostBridgeAuthMiddleware: ClientMiddleware = async function* (call, options) {
|
||||
const token = getHostBridgeToken()
|
||||
if (!token) {
|
||||
return yield* call.next(call.request, options)
|
||||
}
|
||||
const metadata = Metadata(options.metadata).set(HOST_BRIDGE_TOKEN_HEADER, token)
|
||||
return yield* call.next(call.request, { ...options, metadata })
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an authenticated Host Bridge client. Used by the generated clients in
|
||||
* place of nice-grpc's `createClient` — see scripts/generate-host-bridge-client.mjs.
|
||||
*/
|
||||
export function createHostBridgeClient<Service extends CompatServiceDefinition>(definition: Service, channel: Channel) {
|
||||
return createClientFactory().use(hostBridgeAuthMiddleware).create(definition, channel)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import * as getLatestOutputModule from "./get-latest-output"
|
||||
import { VscodeTerminalProcess } from "./VscodeTerminalProcess"
|
||||
import { TerminalRegistry } from "./VscodeTerminalRegistry"
|
||||
|
||||
@@ -641,6 +642,73 @@ describe("TerminalProcess (Integration Tests)", () => {
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("unobserved_command").should.be.false()
|
||||
})
|
||||
|
||||
it("should report a silent command with the C marker as a success, not a capture failure", async () => {
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// A command that completes with exit 0 and no output — $null,
|
||||
// git add -A on a clean tree. The C marker proves the read() stream
|
||||
// worked, so the emptiness is genuine (GitHub #13272).
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () => createMockStream([OSC633_C, OSC633_D]),
|
||||
})
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({
|
||||
executeCommand: mockExecuteCommand,
|
||||
}))
|
||||
|
||||
// If the capture-failure fallback fired, it would read the terminal
|
||||
// snapshot and emit a "could not be captured" line.
|
||||
const snapshotStub = sandbox.stub(getLatestOutputModule, "getLatestTerminalOutput").resolves("should-not-be-used")
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
const runPromise = process.run(terminal, "$null")
|
||||
|
||||
// The mock never fires onDidEndTerminalShellExecution, so the
|
||||
// exit-code race after the stream ends always times out.
|
||||
await sandbox.clock.tickAsync(EXIT_CODE_EVENT_TIMEOUT_MS + 1_000)
|
||||
await runPromise
|
||||
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
// The only permitted "line" event is the empty start-of-output
|
||||
// notification the UI spinner relies on — no content, and critically
|
||||
// no "could not be captured" fallback message, may be emitted.
|
||||
const lineEvents = (emitSpy as sinon.SinonSpy).args
|
||||
.filter(([event]) => event === "line")
|
||||
.map(([, line]) => String(line ?? ""))
|
||||
lineEvents.every((line) => line === "").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("unobserved_command").should.be.false()
|
||||
;(snapshotStub as sinon.SinonStub).called.should.be.false()
|
||||
})
|
||||
|
||||
it("should still use the terminal snapshot fallback when neither markers nor output arrive", async () => {
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// No C marker ever arrives, so an empty output cannot be trusted as
|
||||
// a silent success — the capture-failure fallback must still fire.
|
||||
stubHangingShellIntegration(terminal, [])
|
||||
|
||||
const snapshotStub = sandbox
|
||||
.stub(getLatestOutputModule, "getLatestTerminalOutput")
|
||||
.resolves("terminal snapshot content")
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
const runPromise = process.run(terminal, "some-command")
|
||||
|
||||
// No data ever: the first idle timeout (10s) with prompt-stepped
|
||||
// idle checks up to the 30s cap. Add slack for the exit-code race.
|
||||
await sandbox.clock.tickAsync(60_000)
|
||||
await runPromise
|
||||
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
// The fallback message must reach the agent with the snapshot.
|
||||
const fallbackLines = (emitSpy as sinon.SinonSpy).args
|
||||
.filter(([event]) => event === "line")
|
||||
.map(([, line]) => String(line))
|
||||
fallbackLines.should.matchAny(/could not be captured through shell integration/)
|
||||
fallbackLines.should.matchAny(/terminal snapshot content/)
|
||||
;(snapshotStub as sinon.SinonStub).called.should.be.true()
|
||||
})
|
||||
|
||||
it("should complete when the terminal closes mid-command", async () => {
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
@@ -101,13 +101,12 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
// Text after C is the command's actual output; everything before (prompt,
|
||||
// command echo) is naturally excluded by the marker.
|
||||
//
|
||||
// NOTE: The CommandFinished (D) marker and its exit code do NOT appear in
|
||||
// the read() stream. VS Code's shell integration addon consumes the D
|
||||
// sequence synchronously and fires onDidEndTerminalShellExecution (with the
|
||||
// exit code) before the debounced data event reaches the stream. We listen
|
||||
// to that event to capture the exit code; the D-marker parsing in the parser
|
||||
// is kept only to delimit command output segments (see below), not as an
|
||||
// exit-code source.
|
||||
// NOTE: VS Code excludes the CommandFinished (D) marker from read().
|
||||
// On normal completion it flushes buffered data and ends the stream
|
||||
// before firing onDidEndTerminalShellExecution with the exit code.
|
||||
// Starting another execution can force the previous end event before
|
||||
// its stream finishes flushing. We use the event for the exit code;
|
||||
// D-marker parsing only delimits output segments, not exit codes.
|
||||
const execution = terminal.shellIntegration.executeCommand(command)
|
||||
const stream = execution.read()
|
||||
const parser = new Osc633Parser()
|
||||
@@ -118,7 +117,7 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
|
||||
// Listen for the shell execution end event to capture the exit code and
|
||||
// independently signal completion. The event normally follows the stream,
|
||||
// but some shells leave read() open after reporting that execution ended.
|
||||
// but a replacement execution can force the event before the stream ends.
|
||||
//
|
||||
// onDidEndTerminalShellExecution has been stable API since VS Code 1.93,
|
||||
// below our minimum supported version (see package.json engines.vscode), so it is
|
||||
@@ -405,7 +404,12 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
}
|
||||
|
||||
// the command process is finished, let's check the output to see if we need to use the terminal capture fallback
|
||||
if (!this.fullOutput.trim()) {
|
||||
// The CommandExecuted (C) marker is parsed out of the same read()
|
||||
// stream as the output, so when it was seen an empty fullOutput is a
|
||||
// genuine silent success ($null, git add -A on a clean tree) — the
|
||||
// stream worked and the command simply printed nothing. Falling back
|
||||
// there reported silent commands as capture failures (GitHub #13272).
|
||||
if (!this.fullOutput.trim() && !didSeeCommandExecuted) {
|
||||
// No output captured via shell integration, trying fallback
|
||||
telemetryService.captureTerminalOutputFailure(
|
||||
terminalClosed ? TerminalOutputFailureReason.TERMINAL_CLOSED : TerminalOutputFailureReason.TIMEOUT,
|
||||
@@ -433,13 +437,15 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
telemetryService.captureTerminalExecution(false, "vscode", "none", fallbackDetails)
|
||||
}
|
||||
} else {
|
||||
// Output was captured, but distinguish *how* it was completed: real
|
||||
// OSC 633 C/D markers ("shell_integration") vs the idle/prompt
|
||||
// heuristic fallback ("markerless_heuristic") when markers never
|
||||
// arrived. Folding the latter into "shell_integration" successes
|
||||
// would inflate the metric this PR's fixes are evaluated against.
|
||||
// A terminal closed mid-command is not a success even though some
|
||||
// output was captured — the command was interrupted.
|
||||
// Output was captured — or the C marker proved the stream worked
|
||||
// and the command legitimately printed nothing. Distinguish *how*
|
||||
// completion was observed: real OSC 633 C/D markers
|
||||
// ("shell_integration") vs the idle/prompt heuristic fallback
|
||||
// ("markerless_heuristic") when markers never arrived. Folding the
|
||||
// latter into "shell_integration" successes would inflate the
|
||||
// metric this PR's fixes are evaluated against. A terminal closed
|
||||
// mid-command is not a success even though some output was
|
||||
// captured — the command was interrupted.
|
||||
telemetryService.captureTerminalExecution(
|
||||
!terminalClosed,
|
||||
"vscode",
|
||||
|
||||
@@ -130,6 +130,28 @@ describe("adaptSdkModelInfo", () => {
|
||||
expect(Object.hasOwn(model, "capabilities")).toBe(false)
|
||||
})
|
||||
|
||||
it("treats declared text-only input modalities as no image support, even without capabilities", () => {
|
||||
const model = adaptSdkModelInfo({ id: "m", modalities: { input: ["text"], output: ["text"] } })
|
||||
expect(model.supportsImages).toBe(false)
|
||||
})
|
||||
|
||||
it("lets declared input modalities override the capability-derived image flag", () => {
|
||||
expect(
|
||||
adaptSdkModelInfo({
|
||||
id: "m",
|
||||
capabilities: ["images"],
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
}).supportsImages,
|
||||
).toBe(false)
|
||||
expect(
|
||||
adaptSdkModelInfo({
|
||||
id: "m",
|
||||
capabilities: ["tools"],
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
}).supportsImages,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("preserves SDK input and output modalities", () => {
|
||||
const model = adaptSdkModelInfo({
|
||||
id: "image-model",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
* | contextWindow | `sdk.contextWindow`, or positive `sdk.maxInputTokens` for legacy display compatibility | safe default: 128_000 |
|
||||
* | maxInputTokens | `sdk.maxInputTokens` if finite number (null = missing) | omitted (undefined) |
|
||||
* | maxTokens | `sdk.maxTokens` if finite number (null = missing) | safe default: -1 |
|
||||
* | supportsImages | capabilities includes `images` or `vision` | safe default: true when capabilities absent |
|
||||
* | supportsImages | `modalities.input` includes `image` when declared, else capabilities includes `images` or `vision` | safe default: true when both absent |
|
||||
* | supportsPromptCache | capabilities includes `prompt-cache` | safe default: false when capabilities absent |
|
||||
* | supportsReasoning | capabilities includes `reasoning` | omitted (undefined) |
|
||||
* | inputPrice | `sdk.pricing.input` if finite number | safe default: 0 |
|
||||
@@ -292,6 +292,12 @@ export function adaptSdkModelInfo(input: unknown): ModelInfo {
|
||||
} else {
|
||||
result.supportsImages = openAiModelInfoSafeDefaults.supportsImages
|
||||
}
|
||||
// Declared input modalities win over capabilities, matching what the provider
|
||||
// layer sends (`supportedInputModalities`), so the UI never offers image input
|
||||
// that the request formatter would strip.
|
||||
if (modalities?.input) {
|
||||
result.supportsImages = modalities.input.includes("image")
|
||||
}
|
||||
|
||||
if (pricing?.cacheRead !== undefined) {
|
||||
result.cacheReadsPrice = pricing.cacheRead
|
||||
|
||||
@@ -7,6 +7,7 @@ import "@/shared/net"
|
||||
import { ExternalCommentReviewController } from "@hosts/external/ExternalCommentReviewController"
|
||||
import { ExternalEditPreview } from "@hosts/external/ExternalEditPreview"
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { captureHostBridgeTokenFromEnvironment } from "@hosts/external/host-bridge-auth"
|
||||
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
|
||||
import { retryOperation } from "@utils/retry"
|
||||
import * as path from "path"
|
||||
@@ -29,12 +30,12 @@ let globalCoreConnection: CoreConnection | undefined
|
||||
let shutdownPromise: Promise<void> | undefined
|
||||
|
||||
async function main() {
|
||||
// Remove the per-spawn secret before initialization can launch provider or
|
||||
// MCP child processes. Descendants must never inherit the credential that
|
||||
// authenticates this core connection.
|
||||
const coreConnectionToken = process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
// Capture the per-spawn secret and scrub it from the environment before
|
||||
// initialization can launch provider or MCP child processes, and before the
|
||||
// environment is logged below. Descendants must never inherit the credential;
|
||||
// it is retained in process memory for the bridge clients and the hello.
|
||||
const coreConnectionToken = captureHostBridgeTokenFromEnvironment()
|
||||
const coreInstanceId = process.env.CLINE_CORE_INSTANCE_ID
|
||||
delete process.env.CLINE_CORE_CONNECTION_TOKEN
|
||||
|
||||
log("\n\n\nStarting cline-core service...\n\n\n")
|
||||
log(`Environment variables: ${JSON.stringify(process.env)}`)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type CoreConnectionMessage, CoreConnectionServiceClient } from "@generated/grpc-js/host/core_connection"
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
import { hostBridgeGrpcMetadata } from "@/hosts/external/host-bridge-auth"
|
||||
|
||||
export interface CoreConnection {
|
||||
close(): void
|
||||
@@ -30,7 +31,10 @@ export function connectCoreStream(
|
||||
"grpc.max_receive_message_length": 256 * 1024 * 1024,
|
||||
"grpc.max_send_message_length": 256 * 1024 * 1024,
|
||||
})
|
||||
const stream = client.connect()
|
||||
// The in-band hello already proves identity for this stream; the header keeps
|
||||
// it uniform with every other bridge call so the host can authenticate at the
|
||||
// transport boundary rather than per-service.
|
||||
const stream = client.connect(hostBridgeGrpcMetadata())
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let connected = false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
import * as protoLoader from "@grpc/proto-loader"
|
||||
import * as health from "grpc-health-check"
|
||||
import { hostBridgeGrpcMetadata } from "@/hosts/external/host-bridge-auth"
|
||||
import { log } from "./utils"
|
||||
|
||||
export const HOSTBRIDGE_PORT = 26041
|
||||
@@ -37,7 +38,9 @@ function createHealthClient(address: string) {
|
||||
|
||||
async function checkHealthOnce(client: any): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
client.check({ service: "" }, (err: unknown, resp: any) => {
|
||||
// The health check crosses the same authenticated boundary as every other
|
||||
// bridge call, so it carries the token too.
|
||||
client.check({ service: "" }, hostBridgeGrpcMetadata(), (err: unknown, resp: any) => {
|
||||
if (err) {
|
||||
console.debug(err.toString())
|
||||
return resolve(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
import { createFileReadExecutor } from "../../../../sdk/packages/core/src/extensions/tools/executors/file-read"
|
||||
|
||||
@@ -88,11 +88,12 @@ export function setCompactionStrategyGlobally(compactionStrategy: GlobalCompacti
|
||||
export type ModelToolName = "web_search"
|
||||
|
||||
export function isModelToolEnabledGlobally(name: ModelToolName): boolean {
|
||||
const filePath = process.env.CLINE_GLOBAL_SETTINGS_PATH ?? ""
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "", "utf8"))
|
||||
return settings.tools?.[name]?.enabled === true
|
||||
const settings = JSON.parse(readFileSync(filePath, "utf8"))
|
||||
return settings.tools?.[name]?.enabled ?? true
|
||||
} catch {
|
||||
return false
|
||||
return !existsSync(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Context } from "mocha"
|
||||
|
||||
export const importedContext = Context
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as assert from "node:assert/strict"
|
||||
import { inspect } from "node:util"
|
||||
import * as mochaNamespace from "mocha"
|
||||
import mocha, { after, afterEach, before, beforeEach, describe, it } from "mocha"
|
||||
|
||||
describe("test-setup.js mocha shim", () => {
|
||||
// The runner installs a fresh BDD interface before loading each test file.
|
||||
const runnerInterface = {
|
||||
after: globalThis.after,
|
||||
afterEach: globalThis.afterEach,
|
||||
before: globalThis.before,
|
||||
beforeEach: globalThis.beforeEach,
|
||||
describe: globalThis.describe,
|
||||
it: globalThis.it,
|
||||
}
|
||||
const requiredMocha = require("mocha")
|
||||
const unsupportedContext = /Mocha export "Context" is not provided by test-setup\.js mocha shim/
|
||||
|
||||
it("uses all six runner functions for named, namespace, default and CommonJS imports", () => {
|
||||
const namedImports = { after, afterEach, before, beforeEach, describe, it }
|
||||
for (const name of Object.keys(runnerInterface) as (keyof typeof runnerInterface)[]) {
|
||||
for (const imported of [namedImports, mochaNamespace, mocha, requiredMocha]) {
|
||||
assert.strictEqual(imported[name], runnerInterface[name], name)
|
||||
}
|
||||
}
|
||||
assert.strictEqual(describe.only, runnerInterface.describe.only)
|
||||
assert.strictEqual(describe.skip, runnerInterface.describe.skip)
|
||||
assert.strictEqual(it.only, runnerInterface.it.only)
|
||||
assert.strictEqual(it.skip, runnerInterface.it.skip)
|
||||
})
|
||||
|
||||
it("identifies an unsupported value in an actual compiled named import", () => {
|
||||
assert.throws(() => require("./fixtures/mocha-unsupported-export"), unsupportedContext)
|
||||
})
|
||||
|
||||
it("identifies unsupported exports through namespace and default imports", () => {
|
||||
assert.throws(() => mochaNamespace.Context, unsupportedContext)
|
||||
assert.throws(() => mocha.Context, unsupportedContext)
|
||||
})
|
||||
|
||||
it("identifies unsupported exports when destructuring require('mocha')", () => {
|
||||
assert.throws(() => {
|
||||
const { Context } = require("mocha")
|
||||
return Context
|
||||
}, unsupportedContext)
|
||||
})
|
||||
|
||||
it("leaves module interop and JavaScript protocol probes alone", async () => {
|
||||
assert.strictEqual(requiredMocha.__esModule, undefined)
|
||||
assert.strictEqual(requiredMocha.default, undefined)
|
||||
assert.strictEqual(requiredMocha[Symbol.toStringTag], undefined)
|
||||
assert.strictEqual(requiredMocha[inspect.custom], undefined)
|
||||
assert.strictEqual(requiredMocha.toJSON, undefined)
|
||||
assert.strictEqual(await Promise.resolve(requiredMocha), requiredMocha)
|
||||
assert.strictEqual(Object.prototype.toString.call(requiredMocha), "[object Object]")
|
||||
assert.doesNotThrow(() => inspect(requiredMocha))
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ const tsConfigPaths = require("tsconfig-paths")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const Module = require("module")
|
||||
const mochaExportNames = Object.keys(require("mocha"))
|
||||
|
||||
const baseUrl = path.resolve(__dirname)
|
||||
|
||||
@@ -29,6 +30,39 @@ tsConfigPaths.register({
|
||||
// The module is ES6 only, but the integration tests are compiled to commonJS.
|
||||
const originalRequire = Module.prototype.require
|
||||
Module.prototype.require = function (id) {
|
||||
// Serve `mocha` imports from the runner's own interface. The runner
|
||||
// (@vscode/test-cli) constructs its Mocha instance from its own copy of
|
||||
// the package; a test file that `require("mocha")`s a second copy gets an
|
||||
// un-setup module whose `currentContext` is never set, and crashes with
|
||||
// "Cannot read properties of undefined (reading 'describe')" at import.
|
||||
// The runner's bdd UI installs describe/it/hooks on globalThis before each
|
||||
// test file is required, so delegating to the globals gives every test the
|
||||
// live runner interface regardless of how "mocha" resolves.
|
||||
if (id === "mocha") {
|
||||
const mochaInterface = {
|
||||
after: globalThis.after,
|
||||
afterEach: globalThis.afterEach,
|
||||
before: globalThis.before,
|
||||
beforeEach: globalThis.beforeEach,
|
||||
describe: globalThis.describe,
|
||||
it: globalThis.it,
|
||||
}
|
||||
// Diagnose unsupported package exports without trapping interop or
|
||||
// tooling probes such as __esModule, then, toJSON or symbols. Enumerable
|
||||
// getters preserve the diagnostic through compiled namespace imports.
|
||||
for (const name of mochaExportNames) {
|
||||
if (!Object.hasOwn(mochaInterface, name)) {
|
||||
Object.defineProperty(mochaInterface, name, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error(`Mocha export "${name}" is not provided by test-setup.js mocha shim`)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return mochaInterface
|
||||
}
|
||||
|
||||
// Intercept requires for @google/genai
|
||||
if (id === "@google/genai") {
|
||||
// Return the mock instead
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import ChatTextArea from "./ChatTextArea"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
supportsImages: true as boolean | undefined,
|
||||
navigateToSettingsModelPicker: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => ({
|
||||
mode: "act",
|
||||
apiConfiguration: {},
|
||||
openRouterModels: {},
|
||||
platform: "darwin",
|
||||
localWorkflowToggles: {},
|
||||
globalWorkflowToggles: {},
|
||||
remoteWorkflowToggles: {},
|
||||
remoteConfigSettings: undefined,
|
||||
navigateToSettingsModelPicker: mocks.navigateToSettingsModelPicker,
|
||||
mcpServers: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/context/PlatformContext", () => ({
|
||||
usePlatform: () => ({ togglePlanActKeys: "Meta+Shift+a" }),
|
||||
}))
|
||||
|
||||
vi.mock("@/hooks/useNormalizedApiConfiguration", () => ({
|
||||
useNormalizedApiConfiguration: () => ({
|
||||
selectedProvider: "anthropic",
|
||||
selectedModelId: "text-only-model",
|
||||
selectedModelInfo: { supportsImages: mocks.supportsImages },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/services/grpc-client", () => ({
|
||||
FileServiceClient: {
|
||||
searchCommits: vi.fn(async () => ({ commits: [] })),
|
||||
searchFiles: vi.fn(async () => ({ results: [] })),
|
||||
getRelativePaths: vi.fn(async () => ({ paths: [] })),
|
||||
openImage: vi.fn(async () => ({})),
|
||||
openFile: vi.fn(async () => ({})),
|
||||
},
|
||||
StateServiceClient: {
|
||||
togglePlanActModeProto: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../cline-rules/ClineRulesToggleModal", () => ({ default: () => null }))
|
||||
vi.mock("./ServersToggleModal", () => ({ default: () => null }))
|
||||
|
||||
const PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgo="
|
||||
|
||||
function renderTextArea(selectedImages: string[] = []) {
|
||||
const setSelectedImages = vi.fn()
|
||||
render(
|
||||
<ChatTextArea
|
||||
activeQuote={null}
|
||||
inputValue=""
|
||||
onSelectFilesAndImages={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
placeholderText="Type a message"
|
||||
selectedFiles={[]}
|
||||
selectedImages={selectedImages}
|
||||
sendingDisabled={false}
|
||||
setInputValue={vi.fn()}
|
||||
setSelectedFiles={vi.fn()}
|
||||
setSelectedImages={setSelectedImages}
|
||||
shouldDisableFilesAndImages={false}
|
||||
/>,
|
||||
)
|
||||
return { textarea: screen.getByPlaceholderText("Type a message"), setSelectedImages }
|
||||
}
|
||||
|
||||
function pasteImage(target: HTMLElement) {
|
||||
const file = new File(["png"], "screenshot.png", { type: "image/png" })
|
||||
return fireEvent.paste(target, {
|
||||
clipboardData: {
|
||||
items: [{ kind: "file", type: "image/png", getAsFile: () => file }],
|
||||
getData: () => "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("ChatTextArea image attachments vs. model capability", () => {
|
||||
beforeEach(() => {
|
||||
mocks.supportsImages = true
|
||||
mocks.navigateToSettingsModelPicker.mockReset()
|
||||
})
|
||||
|
||||
it("still takes the image attach path on paste for a text-only model, without a refusal message", () => {
|
||||
mocks.supportsImages = false
|
||||
const { textarea } = renderTextArea()
|
||||
|
||||
const notCanceled = pasteImage(textarea)
|
||||
|
||||
expect(notCanceled).toBe(false) // preventDefault: the paste was handled as an image, not as text
|
||||
expect(screen.queryByText(/ignored/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("badges attached images and offers a model switch when the model is text-only", () => {
|
||||
mocks.supportsImages = false
|
||||
renderTextArea([PNG_DATA_URL, PNG_DATA_URL])
|
||||
|
||||
const notice = screen.getByTestId("images-unsupported-notice")
|
||||
expect(notice).toHaveTextContent("text-only-model doesn't support images, so the 2 attached images will be ignored.")
|
||||
expect(screen.getAllByTestId("image-unsupported-badge")).toHaveLength(2)
|
||||
|
||||
// A native button, so keyboard users get Enter/Space activation without extra handlers.
|
||||
const chooseModel = screen.getByRole("button", { name: "Choose an image-capable model" })
|
||||
expect(chooseModel.tagName).toBe("BUTTON")
|
||||
fireEvent.click(chooseModel)
|
||||
expect(mocks.navigateToSettingsModelPicker).toHaveBeenCalledWith({ targetSection: "api-config" })
|
||||
})
|
||||
|
||||
it("uses singular wording for one image", () => {
|
||||
mocks.supportsImages = false
|
||||
renderTextArea([PNG_DATA_URL])
|
||||
|
||||
expect(screen.getByTestId("images-unsupported-notice")).toHaveTextContent("the attached image will be ignored")
|
||||
expect(screen.getByTestId("images-unsupported-notice")).toHaveTextContent("or remove it.")
|
||||
})
|
||||
|
||||
it("shows nothing extra when the model supports images", () => {
|
||||
renderTextArea([PNG_DATA_URL])
|
||||
|
||||
expect(screen.queryByTestId("images-unsupported-notice")).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId("image-unsupported-badge")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("fails open when the model's image support is unknown", () => {
|
||||
mocks.supportsImages = undefined
|
||||
renderTextArea([PNG_DATA_URL])
|
||||
|
||||
expect(screen.queryByTestId("images-unsupported-notice")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows nothing for a text-only model while no images are attached", () => {
|
||||
mocks.supportsImages = false
|
||||
renderTextArea()
|
||||
|
||||
expect(screen.queryByTestId("images-unsupported-notice")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state
|
||||
import { type SlashCommand } from "@shared/slashCommands"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { AtSignIcon, PlusIcon } from "lucide-react"
|
||||
import { AtSignIcon, PlusIcon, TriangleAlertIcon } from "lucide-react"
|
||||
import type React from "react"
|
||||
import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import DynamicTextArea from "react-textarea-autosize"
|
||||
@@ -260,7 +260,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [, metaKeyChar] = useMetaKeyDetection(platform)
|
||||
const { selectedProvider, selectedModelId } = useNormalizedApiConfiguration(mode)
|
||||
const { selectedProvider, selectedModelId, selectedModelInfo } = useNormalizedApiConfiguration(mode)
|
||||
// Images are attached regardless; when the selected model has no image input the thumbnails get a warning
|
||||
// badge and a notice offers to switch models. Unknown capability data fails open, like core does.
|
||||
const modelSupportsImages = selectedModelInfo.supportsImages !== false
|
||||
const unsupportedImagesAttached = selectedImages.length > 0 && !modelSupportsImages
|
||||
|
||||
// Fetch git commits when Git is selected or when typing a hash
|
||||
useEffect(() => {
|
||||
@@ -1563,6 +1567,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
<Thumbnails
|
||||
files={selectedFiles}
|
||||
images={selectedImages}
|
||||
imagesUnsupported={unsupportedImagesAttached}
|
||||
onHeightChange={handleThumbnailsHeightChange}
|
||||
setFiles={setSelectedFiles}
|
||||
setImages={setSelectedImages}
|
||||
@@ -1592,6 +1597,28 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{unsupportedImagesAttached && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3.5 pb-1.5 text-xs"
|
||||
data-testid="images-unsupported-notice"
|
||||
role="status"
|
||||
style={{ color: "var(--vscode-editorWarning-foreground)" }}>
|
||||
<TriangleAlertIcon className="shrink-0" size={12} />
|
||||
<span className="min-w-0">
|
||||
{selectedModelId} doesn't support images, so{" "}
|
||||
{selectedImages.length === 1 ? "the attached image" : `the ${selectedImages.length} attached images`}{" "}
|
||||
will be ignored.{" "}
|
||||
<button
|
||||
className="underline cursor-pointer bg-transparent border-0 p-0 m-0 text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
|
||||
data-testid="images-unsupported-choose-model"
|
||||
onClick={handleModelButtonClick}
|
||||
type="button">
|
||||
Choose an image-capable model
|
||||
</button>{" "}
|
||||
or remove {selectedImages.length === 1 ? "it" : "them"}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center -mt-[2px] px-3 pb-2">
|
||||
{/* Always render both components, but control visibility with CSS */}
|
||||
<div className="relative flex-1 min-w-0 h-5">
|
||||
|
||||
@@ -12,9 +12,20 @@ interface ThumbnailsProps {
|
||||
setFiles?: React.Dispatch<React.SetStateAction<string[]>>
|
||||
onHeightChange?: (height: number) => void
|
||||
className?: string
|
||||
/** The selected model has no image input: mark image thumbnails as going to be ignored. */
|
||||
imagesUnsupported?: boolean
|
||||
}
|
||||
|
||||
const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange, className }: ThumbnailsProps) => {
|
||||
const Thumbnails = ({
|
||||
images,
|
||||
files,
|
||||
style,
|
||||
setImages,
|
||||
setFiles,
|
||||
onHeightChange,
|
||||
className,
|
||||
imagesUnsupported = false,
|
||||
}: ThumbnailsProps) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width } = useWindowSize()
|
||||
@@ -79,8 +90,31 @@ const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
opacity: imagesUnsupported ? 0.55 : 1,
|
||||
}}
|
||||
/>
|
||||
{imagesUnsupported && (
|
||||
<div
|
||||
data-testid="image-unsupported-badge"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
left: -4,
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-editorWarning-foreground)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
title="The selected model doesn't support images, so this image will be ignored.">
|
||||
<span
|
||||
className="codicon codicon-warning"
|
||||
style={{ color: "var(--vscode-editor-background)", fontSize: 9, fontWeight: "bold" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDeletableImages && hoveredIndex === `image-${index}` && (
|
||||
<div
|
||||
onClick={() => handleDeleteImages(index)}
|
||||
@@ -103,7 +137,8 @@ const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange,
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
}}></span>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +172,8 @@ const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange,
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-foreground)",
|
||||
}}></span>
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 7,
|
||||
@@ -174,7 +210,8 @@ const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange,
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
}}></span>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.60",
|
||||
"version": "3.0.61",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"apps/examples/desktop-app": {
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.22",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@cline/core": "workspace:*",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"apps/vscode": {
|
||||
"name": "claude-dev",
|
||||
"version": "4.1.16",
|
||||
"version": "4.1.17",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
@@ -824,7 +824,7 @@
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"tar": "^7.5.2",
|
||||
"tar-fs": ">=3.1.1",
|
||||
"undici@>=6.0.0 <7.0.0": "6.28.0",
|
||||
"undici": ">=7.29.0 <8",
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
@@ -1225,8 +1225,6 @@
|
||||
|
||||
"@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
"@firebase/ai": ["@firebase/ai@1.4.1", "", { "dependencies": { "@firebase/app-check-interop-types": "0.3.3", "@firebase/component": "0.6.18", "@firebase/logger": "0.4.4", "@firebase/util": "1.12.1", "tslib": "^2.1.0" }, "peerDependencies": { "@firebase/app": "0.x", "@firebase/app-types": "0.x" } }, "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g=="],
|
||||
|
||||
"@firebase/analytics": ["@firebase/analytics@0.10.17", "", { "dependencies": { "@firebase/component": "0.6.18", "@firebase/installations": "0.6.18", "@firebase/logger": "0.4.4", "@firebase/util": "1.12.1", "tslib": "^2.1.0" }, "peerDependencies": { "@firebase/app": "0.x" } }, "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg=="],
|
||||
@@ -5183,8 +5181,6 @@
|
||||
|
||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.53", "", {}, "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.53", "", {}, "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -5783,8 +5779,6 @@
|
||||
|
||||
"discord.js/discord-api-types": ["discord-api-types@0.38.53", "", {}, "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A=="],
|
||||
|
||||
"discord.js/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
|
||||
@@ -6169,6 +6163,28 @@
|
||||
|
||||
"webview/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="],
|
||||
|
||||
"webview-ui/@types/node": ["@types/node@22.15.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gSZyd0Qmv7qvbd2fJ9HGdYmv1yhNdelIA4YOtN6vkcmSwFhthxSEsBgU/JYZcXjWT6DFzoATcHrc52Ckh8SeRA=="],
|
||||
|
||||
"webview-ui/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
|
||||
@@ -6293,8 +6309,6 @@
|
||||
|
||||
"@dotenvx/dotenvx/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"@jerome-benoit/sap-ai-provider/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
|
||||
|
||||
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@@ -6603,8 +6617,6 @@
|
||||
|
||||
"@vscode/vsce/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"ai-sdk-provider-opencode-sdk/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
@@ -6719,8 +6731,6 @@
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"dify-ai-provider/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
@@ -6971,6 +6981,142 @@
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slider/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-switch/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-switch/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="],
|
||||
|
||||
"webview-ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"webview-ui/@vitejs/plugin-react-swc/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
@@ -7209,6 +7355,48 @@
|
||||
|
||||
"vaul/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-dialog/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-hover-card/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-select/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"webview-ui/@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"webview-ui/vitest/@vitest/utils/loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"webview-ui/vitest/chai/check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"overrides": {
|
||||
"axios": "1.18.0",
|
||||
"fast-uri": ">=3.1.4",
|
||||
"undici@>=6.0.0 <7.0.0": "6.28.0",
|
||||
"undici": ">=7.29.0 <8",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/react": "0.4.3",
|
||||
"tar-fs": ">=3.1.1",
|
||||
|
||||
@@ -266,6 +266,16 @@ persists that same ID and its artifacts. Closing a runtime before a user turn
|
||||
therefore leaves no empty history entry, and persistence code never allocates a
|
||||
replacement ID for an unknown session.
|
||||
|
||||
Session history listing filters child rows at the persistence layer. Subagent
|
||||
and team-task sessions are stored in the same table as the roots that spawned
|
||||
them and always sort newer, so `listSessionHistory` asks the runtime host for
|
||||
`rootOnly` rows. `LocalRuntimeHost` passes the option to the session backend,
|
||||
which applies it in the query before the limit; `HubRuntimeHost` sends it as
|
||||
`session.list { limit, rootOnly }` and the hub handler forwards it to its
|
||||
session host. Omitting the flag returns every row, which is what callers that
|
||||
render subagent trees rely on. History keeps a client-side root filter with a
|
||||
widening scan only as a fallback for older hubs that ignore the flag.
|
||||
|
||||
Workspace bootstrap is owned by the runtime that executes the session. Hub
|
||||
clients preserve an omitted `cwd` and `workspaceRoot` across the transport so
|
||||
the hub-side execution host can place the session in the shared chat
|
||||
@@ -294,6 +304,14 @@ the `Sec-WebSocket-Protocol` header and shutdown requests use an
|
||||
public health/build metadata, but they cannot attach to sessions, issue
|
||||
commands, or stop the daemon.
|
||||
|
||||
Remote proxies that authenticate the client-facing WebSocket upgrade with HTTP
|
||||
headers use `NodeHubClient.resolveConnectionHeaders`. The resolver runs for every
|
||||
new socket, including reconnects, so hosts can refresh short-lived credentials.
|
||||
Header authentication is mutually exclusive with the local hub-token subprotocol;
|
||||
the proxy is responsible for authenticating the client and adding any private
|
||||
upstream hub credentials. Resolver failures and rejected protocol headers fail the
|
||||
connection and remain available through the client's connection-error state.
|
||||
|
||||
Local hub rediscovery is limited to managed shared-daemon endpoints obtained
|
||||
through discovery or `ensure*HubServer(...)` startup paths. Managed local hubs
|
||||
must match both the supported wire protocol and the current Hub build identity;
|
||||
@@ -499,6 +517,7 @@ Design implications:
|
||||
- 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
|
||||
- a session imported from another coding agent (`metadata.importedFrom`) that is resumed without compaction state summarizes its whole foreign transcript on the first turn, regardless of the auto-compaction setting; the summary persists as normal compaction state, so the model never replays the source agent's tool calls while the canonical transcript stays intact
|
||||
- `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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
captureTaskCompleted,
|
||||
captureTaskCreated,
|
||||
captureTaskRestarted,
|
||||
captureTokenUsage,
|
||||
captureWorkspaceInitError,
|
||||
captureWorkspaceInitialized,
|
||||
captureWorkspacePathResolved,
|
||||
@@ -242,7 +243,8 @@ async function main() {
|
||||
const t = new CapturingTelemetry();
|
||||
captureTaskCreated(asITelemetryService(t), {
|
||||
ulid: "ulid-smoke-task-1",
|
||||
apiProvider: "anthropic",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
dumpEvents(t.events);
|
||||
const created = t.events.filter((e) => e.name === "task.created");
|
||||
@@ -257,7 +259,8 @@ async function main() {
|
||||
const t = new CapturingTelemetry();
|
||||
captureTaskRestarted(asITelemetryService(t), {
|
||||
ulid: "ulid-smoke-task-1",
|
||||
apiProvider: "anthropic",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
dumpEvents(t.events);
|
||||
const restarted = t.events.filter((e) => e.name === "task.restarted");
|
||||
@@ -314,6 +317,36 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
header("task.tokens (per-request token and cost dimensions)");
|
||||
{
|
||||
const t = new CapturingTelemetry();
|
||||
captureTokenUsage(asITelemetryService(t), {
|
||||
ulid: "ulid-smoke-task-1",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
tokensIn: 125,
|
||||
tokensOut: 50,
|
||||
cacheReadTokens: 75,
|
||||
cacheWriteTokens: 25,
|
||||
totalCost: 0.0125,
|
||||
});
|
||||
dumpEvents(t.events);
|
||||
const tokenEvents = t.events.filter(
|
||||
(event) => event.name === "task.tokens",
|
||||
);
|
||||
assertSmoke("task.tokens count", tokenEvents.length, 1);
|
||||
assertSmoke(
|
||||
"task.tokens carries cacheReadTokens",
|
||||
tokenEvents[0]?.properties?.cacheReadTokens === 75,
|
||||
true,
|
||||
);
|
||||
assertSmoke(
|
||||
"task.tokens carries totalCost",
|
||||
tokenEvents[0]?.properties?.totalCost === 0.0125,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
header(
|
||||
"task.completed source=submit_and_exit (assistant declared completion)",
|
||||
);
|
||||
@@ -322,7 +355,7 @@ async function main() {
|
||||
captureTaskCompleted(asITelemetryService(t), {
|
||||
ulid: "ulid-smoke-task-1",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
model: "claude-sonnet-4-5",
|
||||
mode: "act",
|
||||
durationMs: 12_345,
|
||||
source: "submit_and_exit",
|
||||
@@ -351,7 +384,7 @@ async function main() {
|
||||
captureTaskCompleted(asITelemetryService(t), {
|
||||
ulid: "ulid-smoke-task-2",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
model: "claude-sonnet-4-5",
|
||||
mode: "act",
|
||||
durationMs: 9_876,
|
||||
source: "shutdown",
|
||||
@@ -381,7 +414,8 @@ async function main() {
|
||||
const ulid = "ulid-smoke-task-lifecycle";
|
||||
captureTaskCreated(tSvc, {
|
||||
ulid,
|
||||
apiProvider: "anthropic",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
});
|
||||
captureConversationTurnEvent(tSvc, {
|
||||
ulid,
|
||||
@@ -400,7 +434,7 @@ async function main() {
|
||||
captureTaskCompleted(tSvc, {
|
||||
ulid,
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
model: "claude-sonnet-4-5",
|
||||
mode: "act",
|
||||
durationMs: 4_242,
|
||||
source: "submit_and_exit",
|
||||
|
||||
@@ -645,7 +645,7 @@ describe("ClineCore", () => {
|
||||
const core = await ClineCore.create();
|
||||
const [row] = await core.list(10);
|
||||
|
||||
expect(host.listSessions).toHaveBeenCalledWith(20);
|
||||
expect(host.listSessions).toHaveBeenCalledWith(20, { rootOnly: true });
|
||||
expect(host.readSessionMessages).toHaveBeenCalledWith("session-3");
|
||||
expect(row).toMatchObject({
|
||||
sessionId: "session-3",
|
||||
@@ -703,7 +703,7 @@ describe("ClineCore", () => {
|
||||
// Hydration and default root-session filtering are consumed by
|
||||
// ClineCore/listSessionHistory; the host list contract only receives the
|
||||
// numeric scan limit.
|
||||
expect(host.listSessions.mock.calls).toEqual([[20]]);
|
||||
expect(host.listSessions.mock.calls).toEqual([[20, { rootOnly: true }]]);
|
||||
expect(host.readSessionMessages).not.toHaveBeenCalled();
|
||||
expect(row).toMatchObject({
|
||||
sessionId: "session-lightweight",
|
||||
|
||||
@@ -639,6 +639,17 @@ export class ClineCore {
|
||||
): () => void {
|
||||
return this.host.subscribe(listener, options);
|
||||
}
|
||||
/**
|
||||
* Whether this instance is subscribed to a session's live events.
|
||||
*
|
||||
* In hub mode ClineCore subscribes to a session when it starts, sends to,
|
||||
* or lists pending prompts for it, and unsubscribes on stop. A client that
|
||||
* also observes the hub directly can use this to render one copy of the
|
||||
* session's events instead of both.
|
||||
*/
|
||||
hasSessionSubscription(sessionId: string): boolean {
|
||||
return this.host.hasSessionSubscription?.(sessionId) ?? false;
|
||||
}
|
||||
/**
|
||||
* Updates the AI model used by an active session.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,11 @@ export {
|
||||
type ProviderActionExecutor,
|
||||
RpcClineAccountService,
|
||||
} from "./rpc";
|
||||
export {
|
||||
type ClineAccountTelemetryIdentity,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
} from "./telemetry";
|
||||
export type {
|
||||
ClineAccountBalance,
|
||||
ClineAccountOrganization,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
persistClineAccountTelemetryIdentity,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
} from "./telemetry";
|
||||
import type { ClineAccountUser } from "./types";
|
||||
|
||||
function createUser(
|
||||
overrides: Partial<ClineAccountUser> = {},
|
||||
): ClineAccountUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Cline account telemetry identity", () => {
|
||||
it("resolves the active organization", () => {
|
||||
const identity = resolveClineAccountTelemetryIdentity(
|
||||
createUser({
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(identity).toEqual({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists organization context for detached runtimes", () => {
|
||||
const saveProviderSettings = vi.fn();
|
||||
const manager = {
|
||||
getProviderSettings: vi.fn(() => ({
|
||||
provider: "cline" as const,
|
||||
auth: { accountId: "user-1", accessToken: "secret" },
|
||||
})),
|
||||
saveProviderSettings,
|
||||
};
|
||||
const identity = resolveClineAccountTelemetryIdentity(
|
||||
createUser({
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(persistClineAccountTelemetryIdentity(manager, identity)).toBe(true);
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accountId: "user-1",
|
||||
accessToken: "secret",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces stale persisted account identity after an account switch", () => {
|
||||
const saveProviderSettings = vi.fn();
|
||||
const manager = {
|
||||
getProviderSettings: vi.fn(() => ({
|
||||
provider: "cline" as const,
|
||||
auth: { accountId: "old-user", accessToken: "secret" },
|
||||
})),
|
||||
saveProviderSettings,
|
||||
};
|
||||
|
||||
persistClineAccountTelemetryIdentity(manager, {
|
||||
id: "new-user",
|
||||
provider: "cline",
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({ accountId: "new-user" }),
|
||||
}),
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ProviderSettingsManager } from "../services/storage/provider-settings-manager";
|
||||
import type { ClineAccountUser } from "./types";
|
||||
|
||||
/** Account identity shared by telemetry and feature-flag integrations. */
|
||||
export interface ClineAccountTelemetryIdentity {
|
||||
id: string;
|
||||
email?: string;
|
||||
provider: "cline";
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
memberId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user and active organization once so every client
|
||||
* applies the same cross-surface account dimensions.
|
||||
*/
|
||||
export function resolveClineAccountTelemetryIdentity(
|
||||
user: ClineAccountUser,
|
||||
): ClineAccountTelemetryIdentity {
|
||||
const activeOrganization = user.organizations?.find(
|
||||
(organization) => organization.active,
|
||||
);
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the latest active organization alongside OAuth settings. Detached
|
||||
* runtime processes (notably the shared Hub) can then enrich task telemetry
|
||||
* without making their own account API request.
|
||||
*/
|
||||
export function persistClineAccountTelemetryIdentity(
|
||||
manager: Pick<
|
||||
ProviderSettingsManager,
|
||||
"getProviderSettings" | "saveProviderSettings"
|
||||
>,
|
||||
identity: ClineAccountTelemetryIdentity,
|
||||
): boolean {
|
||||
try {
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) return false;
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: identity.id,
|
||||
organizationId: identity.organizationId,
|
||||
organizationName: identity.organizationName,
|
||||
memberId: identity.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// Account display and telemetry must continue if settings are read-only.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DEFAULT_MAX_INPUT_TOKENS,
|
||||
DEFAULT_PRESERVE_RECENT_TOKENS,
|
||||
DEFAULT_TARGET_RATIO,
|
||||
findLatestSummaryIndex,
|
||||
resolveEffectiveMaxInputTokens,
|
||||
} from "./compaction-shared";
|
||||
|
||||
@@ -640,6 +641,69 @@ export function createContextCompactionPrepareTurn(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compaction policy for resuming a session imported from another coding agent.
|
||||
* The imported transcript keeps that agent's tool names and input schemas
|
||||
* verbatim, which a model continuing it may try to call, so the first turn
|
||||
* folds the whole foreign history into a summary before the model request
|
||||
* (manual mode, agentic strategy, nothing preserved but the new prompt).
|
||||
* Runs regardless of the session's auto-compaction setting, tags its status
|
||||
* notices with `importedFrom` so clients can label the wait, and on failure
|
||||
* falls back to the raw transcript. It makes one attempt per session start
|
||||
* (an aborted attempt does not count) and stands down once the working
|
||||
* context already opens with a compaction summary, which is how a resumed
|
||||
* sidecar presents; every other turn defers to `next`, the session's normal
|
||||
* compaction (if any).
|
||||
*/
|
||||
export function createImportedHistoryCompactionPrepareTurn(input: {
|
||||
config: Parameters<typeof createContextCompactionPrepareTurn>[0];
|
||||
/** Source tool id from the session's `importedFrom` metadata. */
|
||||
importedFrom: string;
|
||||
next?: ContextPipelinePrepareTurn;
|
||||
}): ContextPipelinePrepareTurn {
|
||||
const summarize = createContextCompactionPrepareTurn(
|
||||
{
|
||||
...input.config,
|
||||
compaction: {
|
||||
...input.config.compaction,
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
preserveRecentTokens: 0,
|
||||
},
|
||||
},
|
||||
{ mode: "manual" },
|
||||
);
|
||||
let pending = summarize !== undefined;
|
||||
return async (context) => {
|
||||
if (pending && summarize && findLatestSummaryIndex(context.messages) < 0) {
|
||||
try {
|
||||
const result = await summarize({
|
||||
...context,
|
||||
emitStatusNotice: (message, metadata) =>
|
||||
context.emitStatusNotice?.(message, {
|
||||
...metadata,
|
||||
importedFrom: input.importedFrom,
|
||||
}),
|
||||
});
|
||||
pending = false;
|
||||
if (result?.messages) return result;
|
||||
} catch (error) {
|
||||
if (context.abortSignal.aborted) throw error;
|
||||
pending = false;
|
||||
input.config.logger?.log(
|
||||
"Failed to summarize imported session on resume; continuing with the raw transcript",
|
||||
{
|
||||
severity: "warn",
|
||||
sessionId: input.config.sessionId,
|
||||
...describeCompactionError(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return input.next?.(context);
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompactionStateAwarePrepareTurn(input: {
|
||||
compact?: ContextPipelinePrepareTurn;
|
||||
getState?: () => SessionCompactionState | undefined;
|
||||
|
||||
@@ -233,6 +233,31 @@ describe("createApplyPatchExecutor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to add a file that already exists", async () => {
|
||||
const filePath = path.join(tempDir, "note.txt");
|
||||
await fs.writeFile(filePath, "important data", "utf-8");
|
||||
const execute = createApplyPatchExecutor();
|
||||
|
||||
await expect(
|
||||
execute(
|
||||
{
|
||||
input: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: note.txt",
|
||||
"+overwritten",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
tempDir,
|
||||
{} as never,
|
||||
),
|
||||
).rejects.toThrow("Add File Error: File already exists: note.txt");
|
||||
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(
|
||||
"important data",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects incomplete patch sentinels", async () => {
|
||||
const execute = createApplyPatchExecutor();
|
||||
|
||||
|
||||
@@ -227,6 +227,20 @@ async function loadFiles(
|
||||
eols[filePath] = detectLineEnding(fileContent);
|
||||
}
|
||||
|
||||
// ADD targets are loaded only when they already exist, so the parser's
|
||||
// "File already exists" guard reflects the real filesystem instead of
|
||||
// silently overwriting the file.
|
||||
for (const filePath of extractFilesForOperations(lines, [
|
||||
PATCH_MARKERS.ADD,
|
||||
])) {
|
||||
const absolutePath = resolveFilePath(cwd, filePath, restrictToCwd);
|
||||
try {
|
||||
files[filePath] = await fs.readFile(absolutePath, encoding);
|
||||
} catch {
|
||||
// Missing file is the expected case for ADD.
|
||||
}
|
||||
}
|
||||
|
||||
return { files, eols };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
@@ -1040,3 +1041,130 @@ describe.runIf(process.platform === "win32")("createWindowsExecutor", () => {
|
||||
expect(output.trim()).toBe("shell-ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createShellExecutor with inherited stdio", () => {
|
||||
// A backgrounded child (`cmd &`, nohup) inherits the stdio pipe
|
||||
// write-ends, so the shell's 'close' event never fires after it exits.
|
||||
// Before the exit-grace completion this hung the command until the timeout
|
||||
// killed the whole tree (GitHub #12417). POSIX shells reproduce this
|
||||
// directly; on Windows the Git Bash shell does too.
|
||||
const bashShell =
|
||||
process.platform === "win32"
|
||||
? "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
: "/bin/bash";
|
||||
const hasBashShell = existsSync(bashShell);
|
||||
|
||||
it.runIf(hasBashShell)(
|
||||
"completes when a background child keeps the stdio pipes open after the shell exits",
|
||||
async () => {
|
||||
const executor = createShellExecutor({
|
||||
shell: bashShell,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
// sleep outlives the 5s timeout, so without exit-grace completion
|
||||
// this command times out instead of returning the echo output.
|
||||
const output = await executor("sleep 8 & echo done", process.cwd(), ctx);
|
||||
expect(output).toContain("done");
|
||||
expect(output).toContain("background processes still running");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(hasBashShell)(
|
||||
"reports a failing exit code when a background child keeps the stdio pipes open",
|
||||
async () => {
|
||||
const executor = createShellExecutor({
|
||||
shell: bashShell,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
let error: unknown;
|
||||
try {
|
||||
await executor("sleep 8 & echo oops; exit 3", process.cwd(), ctx);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(CommandExitError);
|
||||
expect((error as CommandExitError).exitCode).toBe(3);
|
||||
expect((error as CommandExitError).output).toContain("oops");
|
||||
expect((error as CommandExitError).output).toContain(
|
||||
"background processes still running",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Detached variant: the user has already proceeded while the command
|
||||
// runs, so the promise has settled and only the detached log is left. The
|
||||
// shell exits while the backgrounded sleep holds the pipes, so 'close'
|
||||
// never arrives - the exit-grace path must write the exit record and
|
||||
// complete the log instead of leaving it in the active state.
|
||||
it.runIf(hasBashShell)(
|
||||
"finalizes a detached log when the shell exits while a background child holds the pipes",
|
||||
async () => {
|
||||
const controller = new RunCommandExecutionController();
|
||||
let commandStarted = false;
|
||||
let detachReady = false;
|
||||
let resolveDetachReady: (() => void) | undefined;
|
||||
const readyToDetach = new Promise<void>((resolve) => {
|
||||
resolveDetachReady = resolve;
|
||||
});
|
||||
const resolveWhenReady = () => {
|
||||
if (commandStarted && detachReady) resolveDetachReady?.();
|
||||
};
|
||||
const executor = createShellExecutor({
|
||||
shell: bashShell,
|
||||
timeoutMs: 10_000,
|
||||
executionController: controller,
|
||||
detachedLogRetentionMs: 5_000,
|
||||
processStartTokenProbe: (pid) => ({
|
||||
status: "found",
|
||||
token: `test-process-${pid}`,
|
||||
}),
|
||||
});
|
||||
// The shell stays in the foreground for a second (so the command
|
||||
// can be detached first), then exits while the backgrounded sleep -
|
||||
// which outlives the whole test - still holds the stdio pipes, so
|
||||
// 'close' can never arrive within the assertion window.
|
||||
const execution = executor(
|
||||
{
|
||||
command: bashShell,
|
||||
args: ["-c", "sleep 30 & echo started; sleep 1"],
|
||||
},
|
||||
process.cwd(),
|
||||
{
|
||||
...ctx,
|
||||
sessionId: "session-detach-bg",
|
||||
toolCallId: "call-detach-bg",
|
||||
emitUpdate: (update) => {
|
||||
const payload = update as Record<string, unknown>;
|
||||
if (
|
||||
typeof payload.chunk === "string" &&
|
||||
payload.chunk.startsWith("started")
|
||||
) {
|
||||
commandStarted = true;
|
||||
}
|
||||
if (payload.detachable === true) detachReady = true;
|
||||
resolveWhenReady();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await readyToDetach;
|
||||
expect(
|
||||
controller.proceedWhileRunning("session-detach-bg", "call-detach-bg"),
|
||||
).toBe(1);
|
||||
const result = await execution;
|
||||
expect(result).toContain("started");
|
||||
// The detach notice names the log file the rest of the output goes to.
|
||||
const logPath = /Output will continue in (.+?)\]/.exec(result)?.[1];
|
||||
expect(logPath).toBeDefined();
|
||||
|
||||
const completedAtPath = join(dirname(logPath as string), "completed-at");
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!(await fileExists(completedAtPath)) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
const logText = await readFile(logPath as string, "utf8");
|
||||
expect(logText).toContain("[Command exited with code 0]");
|
||||
expect(await fileExists(completedAtPath)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,12 @@ const DETACHED_LOG_FILENAME = "output.log";
|
||||
const DETACHED_LOG_ACTIVE_COMMAND_FILENAME = "active-command.json";
|
||||
const DETACHED_LOG_COMPLETED_FILENAME = "completed-at";
|
||||
const COMMAND_PROGRESS_FLUSH_INTERVAL_MS = 48;
|
||||
/**
|
||||
* How long the executor waits after the shell process exits for its stdio
|
||||
* streams to close before completing a command whose background children
|
||||
* still hold the pipes open.
|
||||
*/
|
||||
const EXIT_STREAM_GRACE_MS = 1_000;
|
||||
|
||||
type CommandProgressStream = "stdout" | "stderr";
|
||||
|
||||
@@ -692,6 +698,8 @@ function spawnAndCollect(
|
||||
let processStartToken: string | undefined;
|
||||
let killed = false;
|
||||
let settled = false;
|
||||
let settledOnExitGrace = false;
|
||||
let exitStreamGraceTimer: NodeJS.Timeout | undefined;
|
||||
let detached = false;
|
||||
let detachedLog: ReturnType<typeof createDetachedLog> | undefined;
|
||||
let unregisterExecution = () => {};
|
||||
@@ -758,6 +766,9 @@ function spawnAndCollect(
|
||||
const abortHandler = () => killAndReject(new Error("Command was aborted"));
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (exitStreamGraceTimer) {
|
||||
clearTimeout(exitStreamGraceTimer);
|
||||
}
|
||||
context.signal?.removeEventListener("abort", abortHandler);
|
||||
unregisterExecution();
|
||||
};
|
||||
@@ -896,18 +907,13 @@ function spawnAndCollect(
|
||||
progress.append("stderr", chunk);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (killed) return;
|
||||
|
||||
// Shared completion path for 'close' (stdio drained) and the
|
||||
// exit-grace fallback below: snapshot the collectors, flush the final
|
||||
// decoder chunks, and settle with the exit code. `trailingNote` is
|
||||
// appended after truncation so it always reaches the caller.
|
||||
const completeCommand = (code: number | null, trailingNote: string) => {
|
||||
const out = stdout.snapshot();
|
||||
const err = stderr.snapshot();
|
||||
if (detached) {
|
||||
detachedLog?.write(out.finalChunk);
|
||||
detachedLog?.write(err.finalChunk);
|
||||
detachedLog?.write(`\n[Command exited with code ${code ?? 1}]\n`);
|
||||
detachedLog?.complete();
|
||||
return;
|
||||
}
|
||||
if (out.finalChunk) {
|
||||
progress.append("stdout", out.finalChunk);
|
||||
}
|
||||
@@ -936,7 +942,14 @@ function spawnAndCollect(
|
||||
failureOutput.length > 0
|
||||
? `[Command exited with code ${exitCode}]\n${failureOutput}`
|
||||
: `[Command exited with code ${exitCode}]`;
|
||||
settle(() => reject(new CommandExitError(exitCode, result)));
|
||||
settle(() =>
|
||||
reject(
|
||||
new CommandExitError(
|
||||
exitCode,
|
||||
trailingNote ? `${result}\n${trailingNote}` : result,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
let output = combineOutput
|
||||
? out.text + (err.text ? `\n[stderr]\n${err.text}` : "")
|
||||
@@ -951,8 +964,78 @@ function spawnAndCollect(
|
||||
totalChars,
|
||||
});
|
||||
}
|
||||
settle(() => resolve(output));
|
||||
settle(() =>
|
||||
resolve(trailingNote ? `${output}\n${trailingNote}` : output),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (killed || settledOnExitGrace) return;
|
||||
|
||||
const out = stdout.snapshot();
|
||||
const err = stderr.snapshot();
|
||||
if (detached) {
|
||||
// The log is finalized here; the exit-grace timer must not
|
||||
// double-write it later.
|
||||
if (exitStreamGraceTimer) {
|
||||
clearTimeout(exitStreamGraceTimer);
|
||||
}
|
||||
detachedLog?.write(out.finalChunk);
|
||||
detachedLog?.write(err.finalChunk);
|
||||
detachedLog?.write(`\n[Command exited with code ${code ?? 1}]\n`);
|
||||
detachedLog?.complete();
|
||||
return;
|
||||
}
|
||||
completeCommand(code, "");
|
||||
});
|
||||
|
||||
// 'close' fires only once the stdio streams have drained. A command
|
||||
// that backgrounds a child (`cmd &`, Start-Process, nohup) leaves the
|
||||
// inherited pipe write-ends held open, so after the shell itself exits
|
||||
// 'close' never arrives and the command would hang until the timeout
|
||||
// kills the whole tree — even though the command, like a prompt
|
||||
// returning in an interactive terminal, actually finished (GitHub
|
||||
// #12417). When the process has exited and the streams stay open past
|
||||
// a grace period, settle with the exit code and the output collected
|
||||
// so far, and release the stream handles so the host process is not
|
||||
// kept alive by the orphaned pipes. Output still trickling in after
|
||||
// the grace belongs to the background processes and is intentionally
|
||||
// not awaited; a command whose own final output needs more than the
|
||||
// grace to drain after exit is rare, and its collected output is
|
||||
// preserved up to that point. A detached command gets the same
|
||||
// treatment for its log: without this, a detached shell that exits
|
||||
// while a descendant holds the pipes would leave the log forever in
|
||||
// the active state, waiting for a 'close' that never comes.
|
||||
child.on("exit", (code) => {
|
||||
if (killed) return;
|
||||
exitStreamGraceTimer = setTimeout(() => {
|
||||
if (killed || settledOnExitGrace) return;
|
||||
if (detached) {
|
||||
// Write the exit record and complete the log here instead
|
||||
// of leaving it for the startup reaper to retire as stale.
|
||||
const out = stdout.snapshot();
|
||||
const err = stderr.snapshot();
|
||||
detachedLog?.write(out.finalChunk);
|
||||
detachedLog?.write(err.finalChunk);
|
||||
detachedLog?.write(`\n[Command exited with code ${code ?? 1}]\n`);
|
||||
detachedLog?.complete();
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settledOnExitGrace = true;
|
||||
(
|
||||
child.stdout as (typeof child.stdout & { unref?: () => void }) | null
|
||||
)?.unref?.();
|
||||
(
|
||||
child.stderr as (typeof child.stderr & { unref?: () => void }) | null
|
||||
)?.unref?.();
|
||||
child.unref();
|
||||
completeCommand(
|
||||
code,
|
||||
"[Command completed with background processes still running; their output is no longer captured]",
|
||||
);
|
||||
}, EXIT_STREAM_GRACE_MS);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
|
||||
@@ -247,8 +247,13 @@ export function createEditorExecutor(
|
||||
return createFile(filePath, input.new_text, encoding);
|
||||
}
|
||||
if (input.old_text == null) {
|
||||
// Models that fill optional params with null hit this repeatedly and
|
||||
// tend to re-send the identical call; spell out the recovery so the
|
||||
// next call differs.
|
||||
throw new Error(
|
||||
"Parameter `old_text` is required when editing an existing file without `insert_line`",
|
||||
`${filePath} already exists, but \`old_text\` was ${
|
||||
input.old_text === null ? "null" : "omitted"
|
||||
}. To edit an existing file, set \`old_text\` to the exact text in the file that \`new_text\` should replace (read the file first if needed). To insert instead, provide \`insert_line\`. Do not re-send this call unchanged.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -8,7 +9,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type CheckpointEntry,
|
||||
type CheckpointMetadata,
|
||||
checkpointScratchDir,
|
||||
createCheckpointHooks,
|
||||
deleteCheckpointRefs,
|
||||
} from "./checkpoint-hooks";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
@@ -592,4 +595,256 @@ describe("createCheckpointHooks", () => {
|
||||
createCheckpoint.mock.calls.map(([input]) => input.runCount),
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("emits one checkpoint.snapshot event per snapshot attempt without file paths", async () => {
|
||||
const cwd = await createGitRepo();
|
||||
const sessionId = "sess_telemetry";
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
const events: { event: string; properties?: Record<string, unknown> }[] =
|
||||
[];
|
||||
try {
|
||||
const hooks = createCheckpointHooks({
|
||||
cwd,
|
||||
sessionId,
|
||||
telemetry: {
|
||||
capture: (input) => {
|
||||
events.push(input);
|
||||
},
|
||||
},
|
||||
readSessionMetadata: async () => metadata,
|
||||
writeSessionMetadata: async (next) => {
|
||||
metadata = next;
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(join(cwd, "loose-data.txt"), "loose\n", "utf8");
|
||||
await runCheckpointHooks(hooks);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.event).toBe("checkpoint.snapshot");
|
||||
expect(events[0]?.properties).toMatchObject({
|
||||
sessionId,
|
||||
runCount: 1,
|
||||
outcome: "stash",
|
||||
});
|
||||
expect(typeof events[0]?.properties?.durationMs).toBe("number");
|
||||
// Durations and outcomes only — never workspace file paths.
|
||||
expect(JSON.stringify(events[0])).not.toContain("loose-data");
|
||||
} finally {
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("drops persistent-index entries for untracked files that disappear between runs", async () => {
|
||||
// The per-session GIT_INDEX_FILE caches untracked hashes across turns so
|
||||
// unchanged files are not re-hashed; a file that was deleted (or became
|
||||
// tracked) in the meantime must not ghost into later snapshots.
|
||||
const cwd = await createGitRepo();
|
||||
const sessionId = "sess_stale_index";
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const hooks = createCheckpointHooks({
|
||||
cwd,
|
||||
sessionId,
|
||||
readSessionMetadata: async () => metadata,
|
||||
writeSessionMetadata: async (next) => {
|
||||
metadata = next;
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(join(cwd, "a.txt"), "a\n", "utf8");
|
||||
await writeFile(join(cwd, "b.txt"), "b\n", "utf8");
|
||||
await runCheckpointHooks(hooks);
|
||||
|
||||
const first = (metadata?.checkpoint as CheckpointMetadata).latest;
|
||||
const firstTree = await runGit(
|
||||
cwd,
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"--name-only",
|
||||
`${first.ref}^3`,
|
||||
);
|
||||
expect(firstTree.split("\n").sort()).toEqual(["a.txt", "b.txt"]);
|
||||
// The scratch index survives the turn — that is the cross-turn cache.
|
||||
expect(
|
||||
existsSync(join(checkpointScratchDir(cwd, sessionId), "index")),
|
||||
).toBe(true);
|
||||
|
||||
await rm(join(cwd, "a.txt"));
|
||||
await runCheckpointHooks(hooks, {
|
||||
messages: [userMessage("first request"), userMessage("second request")],
|
||||
});
|
||||
|
||||
const second = (metadata?.checkpoint as CheckpointMetadata).latest;
|
||||
expect(second.runCount).toBe(2);
|
||||
const secondTree = await runGit(
|
||||
cwd,
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"--name-only",
|
||||
`${second.ref}^3`,
|
||||
);
|
||||
expect(secondTree.split("\n")).toEqual(["b.txt"]);
|
||||
} finally {
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the per-session scratch dir together with the checkpoint refs", async () => {
|
||||
const cwd = await createGitRepo();
|
||||
const sessionId = "sess_scratch_cleanup";
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const hooks = createCheckpointHooks({
|
||||
cwd,
|
||||
sessionId,
|
||||
readSessionMetadata: async () => metadata,
|
||||
writeSessionMetadata: async (next) => {
|
||||
metadata = next;
|
||||
},
|
||||
});
|
||||
await writeFile(join(cwd, "loose.txt"), "loose\n", "utf8");
|
||||
await runCheckpointHooks(hooks);
|
||||
expect(existsSync(checkpointScratchDir(cwd, sessionId))).toBe(true);
|
||||
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
|
||||
expect(existsSync(checkpointScratchDir(cwd, sessionId))).toBe(false);
|
||||
} finally {
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers from a stale index.lock left by a killed git process", async () => {
|
||||
const cwd = await createGitRepo();
|
||||
const sessionId = "sess_stale_lock";
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const hooks = createCheckpointHooks({
|
||||
cwd,
|
||||
sessionId,
|
||||
readSessionMetadata: async () => metadata,
|
||||
writeSessionMetadata: async (next) => {
|
||||
metadata = next;
|
||||
},
|
||||
});
|
||||
// A SIGKILLed git leaves the lock file next to the persistent index;
|
||||
// the rebuild must clear it or every later turn degrades to HEAD-only.
|
||||
const scratch = checkpointScratchDir(cwd, sessionId);
|
||||
await mkdir(scratch, { recursive: true });
|
||||
await writeFile(join(scratch, "index.lock"), "", "utf8");
|
||||
await writeFile(join(cwd, "loose.txt"), "loose\n", "utf8");
|
||||
|
||||
await runCheckpointHooks(hooks);
|
||||
|
||||
const checkpoint = metadata?.checkpoint as CheckpointMetadata;
|
||||
expect(checkpoint.latest.kind).toBe("stash");
|
||||
const tree = await runGit(
|
||||
cwd,
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"--name-only",
|
||||
`${checkpoint.latest.ref}^3`,
|
||||
);
|
||||
expect(tree.split("\n")).toEqual(["loose.txt"]);
|
||||
} finally {
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an untracked nested repository's gitlink across turns", async () => {
|
||||
// `ls-files --others` reports a nested repo as "sub/" while the index
|
||||
// records the gitlink as "sub"; the stale sweep must not treat that
|
||||
// name difference as a deleted entry.
|
||||
const cwd = await createGitRepo();
|
||||
const sessionId = "sess_nested_repo";
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const sub = join(cwd, "sub");
|
||||
await mkdir(sub);
|
||||
await runGit(sub, "init");
|
||||
await runGit(sub, "config", "user.name", "Codex Test");
|
||||
await runGit(sub, "config", "user.email", "codex@example.com");
|
||||
await writeFile(join(sub, "inner.txt"), "inner\n", "utf8");
|
||||
await runGit(sub, "add", "inner.txt");
|
||||
await runGit(sub, "commit", "-m", "inner");
|
||||
|
||||
const hooks = createCheckpointHooks({
|
||||
cwd,
|
||||
sessionId,
|
||||
readSessionMetadata: async () => metadata,
|
||||
writeSessionMetadata: async (next) => {
|
||||
metadata = next;
|
||||
},
|
||||
});
|
||||
await runCheckpointHooks(hooks);
|
||||
|
||||
const first = (metadata?.checkpoint as CheckpointMetadata).latest;
|
||||
expect(first.kind).toBe("stash");
|
||||
expect(await runGit(cwd, "ls-tree", `${first.ref}^3`)).toMatch(
|
||||
/160000 commit [0-9a-f]{40}\tsub/,
|
||||
);
|
||||
|
||||
// The second turn exercises the stale sweep against the now-populated
|
||||
// persistent index.
|
||||
await writeFile(join(cwd, "extra.txt"), "extra\n", "utf8");
|
||||
await runCheckpointHooks(hooks, {
|
||||
messages: [userMessage("first request"), userMessage("second request")],
|
||||
});
|
||||
const second = (metadata?.checkpoint as CheckpointMetadata).latest;
|
||||
expect(second.runCount).toBe(2);
|
||||
expect(await runGit(cwd, "ls-tree", `${second.ref}^3`)).toMatch(
|
||||
/160000 commit [0-9a-f]{40}\tsub/,
|
||||
);
|
||||
} finally {
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects untracked content changes across turns when core.ignorestat is enabled", async () => {
|
||||
// With core.ignorestat=true git marks entries it writes as
|
||||
// assume-unchanged and stops stat-checking them. The throwaway per-turn
|
||||
// index never carried that bit across turns; a persistent index does,
|
||||
// so without pinning the setting the second snapshot silently keeps the
|
||||
// first turn's content.
|
||||
const cwd = await createGitRepo();
|
||||
const sessionId = "sess_ignorestat";
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await runGit(cwd, "config", "core.ignorestat", "true");
|
||||
const hooks = createCheckpointHooks({
|
||||
cwd,
|
||||
sessionId,
|
||||
readSessionMetadata: async () => metadata,
|
||||
writeSessionMetadata: async (next) => {
|
||||
metadata = next;
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(join(cwd, "data.txt"), "before", "utf8");
|
||||
await runCheckpointHooks(hooks);
|
||||
const first = (metadata?.checkpoint as CheckpointMetadata).latest;
|
||||
expect(await runGit(cwd, "show", `${first.ref}^3:data.txt`)).toBe(
|
||||
"before",
|
||||
);
|
||||
|
||||
await writeFile(join(cwd, "data.txt"), "after!", "utf8");
|
||||
await runCheckpointHooks(hooks, {
|
||||
messages: [userMessage("first request"), userMessage("second request")],
|
||||
});
|
||||
const second = (metadata?.checkpoint as CheckpointMetadata).latest;
|
||||
expect(second.runCount).toBe(2);
|
||||
expect(await runGit(cwd, "show", `${second.ref}^3:data.txt`)).toBe(
|
||||
"after!",
|
||||
);
|
||||
} finally {
|
||||
await deleteCheckpointRefs(cwd, sessionId);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,82 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFile as execFileCallback, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { AgentHooks, BasicLogger } from "@cline/shared";
|
||||
import type { AgentHooks, BasicLogger, ITelemetryService } from "@cline/shared";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
import { countUserRunMessages } from "../session/user-run-messages";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const CHECKPOINT_STASH_MESSAGE_PREFIX = "cline checkpoint session=";
|
||||
const LS_FILES_MAX_BUFFER = 1024 * 1024 * 64;
|
||||
/**
|
||||
* Scratch dirs whose mtime is older than this are reaped opportunistically.
|
||||
* Each snapshot rewrites the index (and so touches the dir), so any live
|
||||
* session refreshes its dir every turn; only sessions idle for this long —
|
||||
* typically ones never explicitly deleted — are affected.
|
||||
*/
|
||||
const SCRATCH_DIR_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isCheckpointStashMessage(message: string): boolean {
|
||||
return message.includes(CHECKPOINT_STASH_MESSAGE_PREFIX);
|
||||
}
|
||||
|
||||
function checkpointScratchBaseDir(): string {
|
||||
return join(resolveClineDataDir(), "checkpoint-scratch");
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session scratch directory holding the persistent `GIT_INDEX_FILE` used
|
||||
* to snapshot untracked files. Persisting the index across turns lets git's
|
||||
* stat cache skip re-reading (and re-hashing) untracked files that have not
|
||||
* changed since the previous checkpoint — without it, every turn re-hashes
|
||||
* every untracked byte in the workspace.
|
||||
*
|
||||
* Lives under the user's Cline data dir (never the world-shared OS tmpdir:
|
||||
* the index and pathspec files enumerate workspace paths) and is keyed by a
|
||||
* hash of cwd + session id: hashing avoids sanitization collisions between
|
||||
* distinct ids, and folding in cwd keeps a session restored against a
|
||||
* different workspace from inheriting a foreign index. Removed by
|
||||
* `deleteCheckpointRefs` and by the age-based reaper.
|
||||
*/
|
||||
export function checkpointScratchDir(cwd: string, sessionId: string): string {
|
||||
const key = createHash("sha256")
|
||||
.update(`${cwd}\0${sessionId}`)
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
return join(checkpointScratchBaseDir(), key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reaper for scratch dirs of sessions that were never explicitly
|
||||
* deleted. Everything under the base dir is ours, so age is the only
|
||||
* criterion. Errors (missing base dir, races with concurrent sessions) are
|
||||
* ignored — the next hook instance tries again.
|
||||
*/
|
||||
async function pruneStaleScratchDirs(): Promise<void> {
|
||||
try {
|
||||
const base = checkpointScratchBaseDir();
|
||||
const cutoff = Date.now() - SCRATCH_DIR_MAX_AGE_MS;
|
||||
const entries = await readdir(base, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
if (!entry.isDirectory()) return;
|
||||
const dir = join(base, entry.name);
|
||||
try {
|
||||
if ((await stat(dir)).mtimeMs < cutoff) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Raced with another process — ignore.
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Base dir missing or unreadable — nothing to prune.
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckpointEntry {
|
||||
ref: string;
|
||||
createdAt: number;
|
||||
@@ -43,6 +107,13 @@ type CreateCheckpointHooksOptions = {
|
||||
sessionId: string;
|
||||
runCount: number;
|
||||
}) => Promise<CheckpointEntry | undefined> | CheckpointEntry | undefined;
|
||||
/**
|
||||
* Emits one `checkpoint.snapshot` event per built-in snapshot attempt so
|
||||
* snapshot cost and degradations (HEAD fallbacks, skipped turns) are
|
||||
* observable in the field, not only in local logs. Properties carry
|
||||
* outcome and duration — never file paths or contents.
|
||||
*/
|
||||
telemetry?: Pick<ITelemetryService, "capture">;
|
||||
};
|
||||
|
||||
function warn(logger: BasicLogger | undefined, message: string): void {
|
||||
@@ -95,18 +166,79 @@ async function runGit(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Config pinned for every command that touches the scratch index. A persistent
|
||||
* index inherits whatever the repo's config makes git write into it, and some
|
||||
* settings break cross-turn change detection: `core.ignorestat=true` marks
|
||||
* added entries assume-unchanged, so later turns never stat them again and a
|
||||
* modified file keeps its first-turn content in every subsequent snapshot. The
|
||||
* throwaway per-turn index of the original implementation was immune to this
|
||||
* by construction. `core.splitIndex` would additionally scatter shared-index
|
||||
* files for our private index into the user's `.git`.
|
||||
*/
|
||||
const SCRATCH_INDEX_GIT_CONFIG = [
|
||||
"-c",
|
||||
"core.ignorestat=false",
|
||||
"-c",
|
||||
"core.splitIndex=false",
|
||||
];
|
||||
|
||||
async function runGitWithIndex(
|
||||
cwd: string,
|
||||
indexFile: string,
|
||||
args: string[],
|
||||
): Promise<string> {
|
||||
const result = await execFile("git", ["-C", cwd, ...args], {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, GIT_INDEX_FILE: indexFile },
|
||||
});
|
||||
const result = await execFile(
|
||||
"git",
|
||||
["-C", cwd, ...SCRATCH_INDEX_GIT_CONFIG, ...args],
|
||||
{
|
||||
windowsHide: true,
|
||||
maxBuffer: LS_FILES_MAX_BUFFER,
|
||||
env: { ...process.env, GIT_INDEX_FILE: indexFile },
|
||||
},
|
||||
);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/** Like `runGitWithIndex`, but feeds `input` to the child's stdin. */
|
||||
function runGitWithIndexStdin(
|
||||
cwd: string,
|
||||
indexFile: string,
|
||||
args: string[],
|
||||
input: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"git",
|
||||
["-C", cwd, ...SCRATCH_INDEX_GIT_CONFIG, ...args],
|
||||
{
|
||||
windowsHide: true,
|
||||
env: { ...process.env, GIT_INDEX_FILE: indexFile },
|
||||
stdio: ["pipe", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(
|
||||
new Error(`git ${args[0]} exited with ${code}: ${stderr.trim()}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
child.stdin.on("error", () => {
|
||||
// The close handler reports the real failure; without this listener a
|
||||
// child that exits before consuming stdin crashes the process (EPIPE).
|
||||
});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a commit whose tree contains only the current untracked (but not
|
||||
* git-ignored) files, without touching the working tree or the real index.
|
||||
@@ -116,44 +248,93 @@ async function runGitWithIndex(
|
||||
*/
|
||||
async function createUntrackedParentCommit(
|
||||
cwd: string,
|
||||
scratchDir: string,
|
||||
): Promise<string | undefined> {
|
||||
const listing = await execFile(
|
||||
"git",
|
||||
["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"],
|
||||
{ windowsHide: true, maxBuffer: 1024 * 1024 * 64 },
|
||||
{ windowsHide: true, maxBuffer: LS_FILES_MAX_BUFFER },
|
||||
);
|
||||
const untrackedFiles = listing.stdout.split("\0").filter(Boolean);
|
||||
if (untrackedFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "cline-checkpoint-"));
|
||||
const indexFile = join(tempDir, "index");
|
||||
const pathspecFile = join(tempDir, "pathspec");
|
||||
// 0700: the index and pathspec enumerate workspace paths — private to the
|
||||
// user (mode is a no-op on Windows, where the data dir is per-user anyway).
|
||||
await mkdir(scratchDir, { recursive: true, mode: 0o700 });
|
||||
const indexFile = join(scratchDir, "index");
|
||||
const pathspecFile = join(scratchDir, "pathspec");
|
||||
// Feed paths via a NUL-delimited pathspec file so large untracked sets
|
||||
// cannot overflow the command-line argument limit.
|
||||
await writeFile(pathspecFile, `${untrackedFiles.join("\0")}\0`);
|
||||
const addArgs = [
|
||||
"add",
|
||||
"--force",
|
||||
"--pathspec-from-file",
|
||||
pathspecFile,
|
||||
"--pathspec-file-nul",
|
||||
];
|
||||
try {
|
||||
// Feed paths via a NUL-delimited pathspec file so large untracked sets
|
||||
// cannot overflow the command-line argument limit.
|
||||
await writeFile(pathspecFile, `${untrackedFiles.join("\0")}\0`);
|
||||
await runGitWithIndex(cwd, indexFile, [
|
||||
"add",
|
||||
"--force",
|
||||
"--pathspec-from-file",
|
||||
pathspecFile,
|
||||
"--pathspec-file-nul",
|
||||
]);
|
||||
const tree = await runGitWithIndex(cwd, indexFile, ["write-tree"]);
|
||||
if (!tree) {
|
||||
return undefined;
|
||||
await runGitWithIndex(cwd, indexFile, addArgs);
|
||||
} catch (error) {
|
||||
// A listed file vanishing before the add is a per-turn race, not index
|
||||
// damage — keep the cache and let the caller degrade this turn only.
|
||||
const stderr = String((error as { stderr?: unknown }).stderr ?? "");
|
||||
if (stderr.includes("did not match any files")) {
|
||||
throw error;
|
||||
}
|
||||
const commit = await runGitWithIndex(cwd, indexFile, [
|
||||
"commit-tree",
|
||||
tree,
|
||||
"-m",
|
||||
"untracked files on cline checkpoint",
|
||||
]);
|
||||
return commit || undefined;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
// A corrupt index or stale index.lock (crash mid-write, SIGKILLed git)
|
||||
// would otherwise fail every turn from here on; clear both and rebuild
|
||||
// once. The retry pays a full re-hash, so anything else propagates.
|
||||
await rm(indexFile, { force: true });
|
||||
await rm(`${indexFile}.lock`, { force: true });
|
||||
await runGitWithIndex(cwd, indexFile, addArgs);
|
||||
}
|
||||
// Drop index entries that fell out of the untracked set (file deleted or
|
||||
// became tracked) so they don't ghost into the snapshot tree — `git add`
|
||||
// with a pathspec never removes entries. Normalize trailing slashes when
|
||||
// comparing: `ls-files --others` reports an untracked nested repo as
|
||||
// "sub/", but the index records its gitlink entry as "sub" — without the
|
||||
// normalization the gitlink would be purged the same turn it was added.
|
||||
const indexedListing = await execFile(
|
||||
"git",
|
||||
["-C", cwd, ...SCRATCH_INDEX_GIT_CONFIG, "ls-files", "-z"],
|
||||
{
|
||||
windowsHide: true,
|
||||
maxBuffer: LS_FILES_MAX_BUFFER,
|
||||
env: { ...process.env, GIT_INDEX_FILE: indexFile },
|
||||
},
|
||||
);
|
||||
const untrackedSet = new Set(
|
||||
untrackedFiles.map((path) =>
|
||||
path.endsWith("/") ? path.slice(0, -1) : path,
|
||||
),
|
||||
);
|
||||
const stale = indexedListing.stdout
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.filter((path) => !untrackedSet.has(path));
|
||||
if (stale.length > 0) {
|
||||
// Paths go over stdin (`-z --stdin`): one git process regardless of how
|
||||
// many entries went stale, and no command-line length limits.
|
||||
await runGitWithIndexStdin(
|
||||
cwd,
|
||||
indexFile,
|
||||
["update-index", "-z", "--force-remove", "--stdin"],
|
||||
`${stale.join("\0")}\0`,
|
||||
);
|
||||
}
|
||||
const tree = await runGitWithIndex(cwd, indexFile, ["write-tree"]);
|
||||
if (!tree) {
|
||||
return undefined;
|
||||
}
|
||||
const commit = await runGitWithIndex(cwd, indexFile, [
|
||||
"commit-tree",
|
||||
tree,
|
||||
"-m",
|
||||
"untracked files on cline checkpoint",
|
||||
]);
|
||||
return commit || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,10 +348,11 @@ async function createUntrackedParentCommit(
|
||||
*/
|
||||
async function createWorktreeStashCommit(
|
||||
cwd: string,
|
||||
scratchDir: string,
|
||||
message: string,
|
||||
): Promise<string | undefined> {
|
||||
const stashRef = (await runGit(cwd, ["stash", "create", message])).stdout;
|
||||
const untrackedParent = await createUntrackedParentCommit(cwd);
|
||||
const untrackedParent = await createUntrackedParentCommit(cwd, scratchDir);
|
||||
|
||||
if (stashRef) {
|
||||
if (!untrackedParent) {
|
||||
@@ -257,6 +439,13 @@ export async function deleteCheckpointRefs(
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (!cwd) return;
|
||||
// The scratch dir (persistent untracked index) is keyed by cwd + session
|
||||
// id. A session whose cwd is unknown at deletion time is covered by the
|
||||
// age-based reaper instead.
|
||||
await rm(checkpointScratchDir(cwd, sessionId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}).catch(() => undefined);
|
||||
const prefix = `refs/cline/checkpoints/${sessionId}/`;
|
||||
try {
|
||||
const { stdout } = await runGit(cwd, [
|
||||
@@ -308,6 +497,9 @@ function upsertCheckpointHistory(
|
||||
export function createCheckpointHooks(
|
||||
options: CreateCheckpointHooksOptions,
|
||||
): AgentHooks {
|
||||
// Reap scratch dirs left behind by sessions that were never explicitly
|
||||
// deleted. Fire-and-forget: pruning failures never affect the session.
|
||||
void pruneStaleScratchDirs();
|
||||
let repoSupported: boolean | undefined;
|
||||
// Number of messages present when the current run started, captured in
|
||||
// beforeRun. For hosts that append the run's prompt *after* beforeRun (e.g.
|
||||
@@ -355,6 +547,26 @@ export function createCheckpointHooks(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
// One event per built-in snapshot attempt. Outcomes: "stash" (full
|
||||
// snapshot), "head_clean" (clean worktree, HEAD entry is the normal
|
||||
// result), "head_fallback" (degraded to HEAD after a failure), and
|
||||
// "skipped" (no checkpoint written this turn). Durations only — never
|
||||
// file paths or contents.
|
||||
const captureSnapshot = (
|
||||
outcome: "stash" | "head_clean" | "head_fallback" | "skipped",
|
||||
): void => {
|
||||
options.telemetry?.capture({
|
||||
event: "checkpoint.snapshot",
|
||||
properties: {
|
||||
sessionId: options.sessionId,
|
||||
runCount,
|
||||
outcome,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createHeadCheckpoint = async (
|
||||
warnPrefix: string,
|
||||
): Promise<CheckpointEntry | undefined> => {
|
||||
@@ -382,16 +594,31 @@ export function createCheckpointHooks(
|
||||
const message = `${CHECKPOINT_STASH_MESSAGE_PREFIX}${options.sessionId} run=${runCount}`;
|
||||
let ref = "";
|
||||
try {
|
||||
ref = (await createWorktreeStashCommit(options.cwd, message)) ?? "";
|
||||
ref =
|
||||
(await createWorktreeStashCommit(
|
||||
options.cwd,
|
||||
checkpointScratchDir(options.cwd, options.sessionId),
|
||||
message,
|
||||
)) ?? "";
|
||||
} catch (error) {
|
||||
warn(
|
||||
options.logger,
|
||||
`Checkpoint snapshot failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Checkpoint snapshot failed after ${Date.now() - startedAt}ms: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return createHeadCheckpoint("Checkpoint HEAD fallback failed");
|
||||
const fallback = await createHeadCheckpoint(
|
||||
"Checkpoint HEAD fallback failed",
|
||||
);
|
||||
captureSnapshot(fallback ? "head_fallback" : "skipped");
|
||||
return fallback;
|
||||
}
|
||||
if (!ref) {
|
||||
return createHeadCheckpoint("Checkpoint HEAD fallback failed");
|
||||
// A clean worktree with no untracked files — the HEAD entry is the
|
||||
// expected result here, not a degradation.
|
||||
const fallback = await createHeadCheckpoint(
|
||||
"Checkpoint HEAD fallback failed",
|
||||
);
|
||||
captureSnapshot(fallback ? "head_clean" : "skipped");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Store the stash commit under a private ref namespace so it is
|
||||
@@ -408,9 +635,11 @@ export function createCheckpointHooks(
|
||||
options.logger,
|
||||
`Checkpoint store failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
captureSnapshot("skipped");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
captureSnapshot("stash");
|
||||
return {
|
||||
ref,
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { NodeHubClient } from ".";
|
||||
|
||||
const servers: WebSocketServer[] = [];
|
||||
|
||||
type CommandContext = {
|
||||
command: string;
|
||||
requestId: string;
|
||||
reply: (
|
||||
result?:
|
||||
| { ok?: true; payload?: unknown }
|
||||
| { ok: false; error: { code: string; message: string } },
|
||||
) => void;
|
||||
};
|
||||
|
||||
async function startHubServer(options?: {
|
||||
onConnection?: (request: IncomingMessage) => void;
|
||||
onCommand?: (context: CommandContext) => void;
|
||||
}): Promise<{ server: WebSocketServer; url: string }> {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
server.on("connection", (socket, request) => {
|
||||
options?.onConnection?.(request);
|
||||
socket.on("message", (data) => {
|
||||
const frame = JSON.parse(data.toString()) as {
|
||||
kind?: string;
|
||||
envelope?: { command?: string; requestId?: string };
|
||||
};
|
||||
if (
|
||||
frame.kind !== "command" ||
|
||||
!frame.envelope?.command ||
|
||||
!frame.envelope.requestId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { command, requestId } = frame.envelope;
|
||||
const context: CommandContext = {
|
||||
command,
|
||||
requestId,
|
||||
reply: (result = {}) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
kind: "reply",
|
||||
envelope: {
|
||||
version: "v1",
|
||||
command,
|
||||
requestId,
|
||||
clientId: "hub",
|
||||
ok: result.ok ?? true,
|
||||
...(result.ok === false
|
||||
? { error: result.error }
|
||||
: { payload: result.payload ?? {} }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
if (options?.onCommand) {
|
||||
options.onCommand(context);
|
||||
} else {
|
||||
context.reply();
|
||||
}
|
||||
});
|
||||
});
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return { server, url: `ws://127.0.0.1:${port}/hub` };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
for (const socket of server.clients) {
|
||||
socket.terminate();
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("NodeHubClient connection headers", () => {
|
||||
it("keeps concurrent connects pending until registration finishes", async () => {
|
||||
let acknowledgeRegistration: (() => void) | undefined;
|
||||
let registrationReceived: (() => void) | undefined;
|
||||
const receivedRegistration = new Promise<void>((resolve) => {
|
||||
registrationReceived = resolve;
|
||||
});
|
||||
const { url } = await startHubServer({
|
||||
onCommand: ({ command, reply }) => {
|
||||
if (command === "client.register") {
|
||||
acknowledgeRegistration = reply;
|
||||
registrationReceived?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
resolveConnectionHeaders: async () => ({
|
||||
Authorization: "Bearer account-token",
|
||||
}),
|
||||
});
|
||||
let firstSettled = false;
|
||||
let secondSettled = false;
|
||||
|
||||
try {
|
||||
const first = client.connect().finally(() => {
|
||||
firstSettled = true;
|
||||
});
|
||||
const second = client.connect().finally(() => {
|
||||
secondSettled = true;
|
||||
});
|
||||
await receivedRegistration;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(firstSettled).toBe(false);
|
||||
expect(secondSettled).toBe(false);
|
||||
acknowledgeRegistration?.();
|
||||
await Promise.all([first, second]);
|
||||
expect(client.isConnected()).toBe(true);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes upgrade headers on reconnect without sending a subprotocol", async () => {
|
||||
const upgrades: Array<{
|
||||
authorization: string | undefined;
|
||||
protocol: string | undefined;
|
||||
}> = [];
|
||||
let resolveReconnect: (() => void) | undefined;
|
||||
const reconnected = new Promise<void>((resolve) => {
|
||||
resolveReconnect = resolve;
|
||||
});
|
||||
const { server, url } = await startHubServer({
|
||||
onConnection: (request) => {
|
||||
upgrades.push({
|
||||
authorization: request.headers.authorization,
|
||||
protocol: request.headers["sec-websocket-protocol"],
|
||||
});
|
||||
if (upgrades.length === 2) {
|
||||
resolveReconnect?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let tokenVersion = 0;
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
resolveConnectionHeaders: async () => ({
|
||||
Authorization: `Bearer token-${++tokenVersion}`,
|
||||
}),
|
||||
});
|
||||
client.subscribe(() => {});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
server.clients.values().next().value?.terminate();
|
||||
await Promise.race([
|
||||
reconnected,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("reconnect timed out")), 3_000),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(upgrades).toEqual([
|
||||
{ authorization: "Bearer token-1", protocol: undefined },
|
||||
{ authorization: "Bearer token-2", protocol: undefined },
|
||||
]);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects connection headers combined with an explicit auth token", () => {
|
||||
expect(
|
||||
() =>
|
||||
new NodeHubClient({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "hub-token",
|
||||
resolveConnectionHeaders: () => ({
|
||||
Authorization: "Bearer account-token",
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Hub connection headers cannot be combined with authToken authentication.",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces resolver failures and reruns the resolver on the next connect", async () => {
|
||||
const { url } = await startHubServer();
|
||||
let attempts = 0;
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
resolveConnectionHeaders: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("token refresh failed: signed out");
|
||||
}
|
||||
return { Authorization: "Bearer account-token" };
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(client.connect()).rejects.toMatchObject({
|
||||
code: "hub_connect_failed",
|
||||
message: expect.stringContaining("signed out"),
|
||||
});
|
||||
expect(client.getConnectionError()).toMatchObject({
|
||||
code: "hub_connect_failed",
|
||||
message: expect.stringContaining("signed out"),
|
||||
});
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
expect(attempts).toBe(2);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a fresh connect supersede an attempt stuck in header resolution after close()", async () => {
|
||||
const { url } = await startHubServer();
|
||||
let releaseFirstResolver:
|
||||
| ((headers: Readonly<Record<string, string>>) => void)
|
||||
| undefined;
|
||||
let attempts = 0;
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
resolveConnectionHeaders: () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
return new Promise<Readonly<Record<string, string>>>((resolve) => {
|
||||
releaseFirstResolver = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ Authorization: "Bearer account-token" });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const doomed = client.connect();
|
||||
client.close();
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
releaseFirstResolver?.({ Authorization: "Bearer stale-token" });
|
||||
await expect(doomed).rejects.toMatchObject({
|
||||
code: "hub_connection_closed",
|
||||
});
|
||||
expect(client.isConnected()).toBe(true);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("times out a hung header resolver instead of pinning connect() forever", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new NodeHubClient({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
resolveConnectionHeaders: () => new Promise(() => {}),
|
||||
});
|
||||
|
||||
try {
|
||||
const connecting = client.connect();
|
||||
const expectation = expect(connecting).rejects.toMatchObject({
|
||||
code: "hub_connect_timeout",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(8_100);
|
||||
await expectation;
|
||||
expect(client.getConnectionError()).toMatchObject({
|
||||
code: "hub_connect_timeout",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the socket when registration fails so reconnect re-registers", async () => {
|
||||
let registrationAttempts = 0;
|
||||
const { url } = await startHubServer({
|
||||
onCommand: ({ command, reply }) => {
|
||||
const isRegister = command === "client.register";
|
||||
if (isRegister) {
|
||||
registrationAttempts += 1;
|
||||
}
|
||||
const rejectRegistration = isRegister && registrationAttempts === 1;
|
||||
reply(
|
||||
rejectRegistration
|
||||
? {
|
||||
ok: false,
|
||||
error: { code: "not_authorized", message: "denied" },
|
||||
}
|
||||
: {},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
resolveConnectionHeaders: async () => ({
|
||||
Authorization: "Bearer account-token",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(client.connect()).rejects.toThrow();
|
||||
expect(client.getConnectionError()?.message).toBe("denied");
|
||||
expect(
|
||||
(client as unknown as { socket?: unknown }).socket,
|
||||
).toBeUndefined();
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
expect(registrationAttempts).toBe(2);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts a registration whose reply raced close(), and never leaves a zombie socket", async () => {
|
||||
let registrationMode: "silent" | "reject" | "ack" = "silent";
|
||||
let connectionCount = 0;
|
||||
let announceRegister: ((requestId: string) => void) | undefined;
|
||||
const { url } = await startHubServer({
|
||||
onConnection: () => {
|
||||
connectionCount += 1;
|
||||
},
|
||||
onCommand: ({ command, requestId, reply }) => {
|
||||
if (command !== "client.register") {
|
||||
return;
|
||||
}
|
||||
if (registrationMode !== "silent") {
|
||||
const rejected = registrationMode === "reject";
|
||||
reply(
|
||||
rejected
|
||||
? {
|
||||
ok: false,
|
||||
error: { code: "not_authorized", message: "expired" },
|
||||
}
|
||||
: {},
|
||||
);
|
||||
}
|
||||
announceRegister?.(requestId);
|
||||
},
|
||||
});
|
||||
|
||||
const client = new NodeHubClient({
|
||||
url,
|
||||
resolveConnectionHeaders: () => ({
|
||||
Authorization: "Bearer account-token",
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const registerRequestId = new Promise<string>((resolve) => {
|
||||
announceRegister = resolve;
|
||||
});
|
||||
const raced = client.connect();
|
||||
const requestId = await registerRequestId;
|
||||
(
|
||||
client as unknown as { handleFrame: (frame: unknown) => void }
|
||||
).handleFrame({
|
||||
kind: "reply",
|
||||
envelope: {
|
||||
version: "v1",
|
||||
command: "client.register",
|
||||
requestId,
|
||||
ok: true,
|
||||
clientId: "hub",
|
||||
payload: {},
|
||||
},
|
||||
});
|
||||
client.close();
|
||||
await expect(raced).rejects.toMatchObject({
|
||||
code: "hub_connection_closed",
|
||||
});
|
||||
expect(client.isConnected()).toBe(false);
|
||||
|
||||
registrationMode = "reject";
|
||||
await expect(client.connect()).rejects.toMatchObject({
|
||||
code: "not_authorized",
|
||||
});
|
||||
expect(client.isConnected()).toBe(false);
|
||||
|
||||
registrationMode = "ack";
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
expect(connectionCount).toBe(3);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects connection headers combined with an auth token in the URL", async () => {
|
||||
const resolveConnectionHeaders = vi.fn(() => ({
|
||||
Authorization: "Bearer account-token",
|
||||
}));
|
||||
const client = new NodeHubClient({
|
||||
url: "ws://127.0.0.1:25463/hub?authToken=hub-token",
|
||||
resolveConnectionHeaders,
|
||||
});
|
||||
|
||||
await expect(client.connect()).rejects.toThrow(
|
||||
"Hub connection headers cannot be combined with authToken authentication.",
|
||||
);
|
||||
expect(resolveConnectionHeaders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not allow connection headers to set a subprotocol", async () => {
|
||||
const client = new NodeHubClient({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
resolveConnectionHeaders: () => ({
|
||||
"Sec-WebSocket-Protocol": "unexpected",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(client.connect()).rejects.toThrow(
|
||||
"Hub connection headers cannot set Sec-WebSocket-Protocol.",
|
||||
);
|
||||
expect(client.getConnectionError()?.message).toBe(
|
||||
"Hub connection headers cannot set Sec-WebSocket-Protocol.",
|
||||
);
|
||||
});
|
||||
});
|
||||