Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac25347a4 | ||
|
|
5383014c3e | ||
|
|
8845c718a7 | ||
|
|
a2fca629b8 | ||
|
|
bfc8e62ea7 | ||
|
|
815de4442d | ||
|
|
bf4462e959 | ||
|
|
4eefc593ee | ||
|
|
4315517fca | ||
|
|
df6c57e97a | ||
|
|
f5f3a19f9e | ||
|
|
168510800b | ||
|
|
a593ca4a6b | ||
|
|
76bc102066 | ||
|
|
9328710dce | ||
|
|
fd4636edc9 | ||
|
|
71fc9681e2 | ||
|
|
3512ae5f65 | ||
|
|
05d3404bab |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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
|
||||
@@ -319,12 +319,6 @@ jobs:
|
||||
# Updater artifact signing (minisign keypair, independent of Apple)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# The DMG background, window size, and icon positions from
|
||||
# tauri.conf.json are applied by a Finder AppleScript in Tauri's
|
||||
# bundle_dmg.sh. When CI=true (always set on Actions) the bundler
|
||||
# passes --skip-jenkins and silently skips that script, shipping a
|
||||
# bare DMG. macOS runners have a GUI session, so opt out of the skip.
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
|
||||
@@ -61,9 +61,8 @@ 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 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.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
|
||||
emitter in `prepareLocalRuntimeBootstrap`. 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
|
||||
@@ -105,19 +104,6 @@ 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, terminal, and desktop.
|
||||
The open source coding agent in your IDE and terminal.
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -44,7 +44,7 @@ The open source coding agent in your IDE, terminal, and desktop.
|
||||
### CLI
|
||||
|
||||
Run Cline in your terminal.
|
||||
Interactive chat or fully headless
|
||||
Interactive chat or fully headless
|
||||
for CI/CD and scripting.
|
||||
|
||||
```
|
||||
@@ -57,13 +57,17 @@ npm i -g cline
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
|
||||
### Desktop App
|
||||
### Kanban
|
||||
|
||||
Cline as a native app for macOS and Windows.
|
||||
Run agent sessions in any folder, schedule
|
||||
routines, and manage models, plugins, and MCP servers.
|
||||
Run many agents in parallel from a
|
||||
web-based task board. Each card gets its own
|
||||
worktree, auto-commit, and dependency chains.
|
||||
|
||||
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
|
||||
```
|
||||
npm i -g kanban
|
||||
```
|
||||
|
||||
<a href="https://github.com/cline/kanban">Learn more</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -104,7 +108,7 @@ the JetBrains family.
|
||||
|
||||
### SDK
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```
|
||||
npm install @cline/sdk
|
||||
@@ -127,8 +131,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
|
||||
|
||||
@@ -91,6 +91,12 @@ Cline CLI runs in a few different shapes depending on what you need:
|
||||
- Yolo: `cline --yolo "..."` skips approval prompts and exits when the turn finishes
|
||||
- Zen: `cline --zen "..."` fires the task to the background hub daemon and exits immediately (see below)
|
||||
|
||||
### Computer use (experimental)
|
||||
|
||||
Start qbt before the interactive CLI and set `CLINE_COMPUTER_USE_PORT` to its agent port. The computer-user helper requires a configured direct Anthropic provider. Optionally set `CLINE_COMPUTER_USE_BACKEND_COMMAND` to a shell command that starts qbt: this adds `computer_user_restart_backend` for recovery when qbt becomes unreachable, not automatic startup. Set these variables before launching the CLI and restart it after changes.
|
||||
|
||||
See the [computer-use setup and backend recovery command](../../sdk/packages/core/src/extensions/computer-use/README.md#backend-recovery-command) for a Windows example, shell rules, ports, and process ownership.
|
||||
|
||||
## Headless mode for CI/CD
|
||||
|
||||
Run Cline with zero interaction for scripting and automation. Pipe input, get JSON output, chain commands, integrate into CI/CD pipelines.
|
||||
@@ -215,7 +221,7 @@ cline connect --stop
|
||||
cline connect --stop telegram
|
||||
```
|
||||
|
||||
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cwd <path>`, `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
|
||||
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cd <path>` (also `/cwd <path>`), `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
|
||||
|
||||
### Schedules
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
// 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.
|
||||
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
|
||||
import type { ChatStartSessionRequest } from "@cline/core";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
|
||||
@@ -57,7 +57,7 @@ The Telegram connector uses the shared connector command parser:
|
||||
- `/whereami` - show thread, cwd, tools, and yolo state
|
||||
- `/tools [on|off|toggle]` - allow or block repo/file/shell tools
|
||||
- `/yolo [on|off|toggle]` - auto-approve tool use
|
||||
- `/cwd <path>` - change working directory
|
||||
- `/cd <path>` or `/cwd <path>` - change working directory
|
||||
- `/schedule create/list/trigger/delete` - manage scheduled workflows
|
||||
- `/abort` - stop the current task
|
||||
- `/exit` - stop the connector
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export async function handleConnectorUserTurn<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
{ mode: startRequest.mode, cwd: startRequest.cwd },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
|
||||
@@ -576,6 +576,53 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("warns that computer use is unavailable when a prompt argument is used", async () => {
|
||||
const stdout = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
process.env.CLINE_COMPUTER_USE_PORT = "1234";
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(stdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"computer use is only available in interactive mode",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete process.env.CLINE_COMPUTER_USE_PORT;
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not warn about computer use in interactive mode", async () => {
|
||||
const stdout = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
process.env.CLINE_COMPUTER_USE_PORT = "1234";
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
try {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(stdout).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"computer use is only available in interactive mode",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete process.env.CLINE_COMPUTER_USE_PORT;
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a single bare positional prompt token", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
normalizeAutoApproveArgs,
|
||||
resolveWorkspaceRoot,
|
||||
} from "./utils/helpers";
|
||||
import { createMutableUserInstructionConfigService } from "./utils/mutable-user-instruction-service";
|
||||
import {
|
||||
c,
|
||||
installStreamErrorGuards,
|
||||
@@ -915,15 +916,22 @@ export async function runCli(): Promise<void> {
|
||||
});
|
||||
coreServer.setSdkLogger(loggerAdapter.core);
|
||||
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
includePluginSkills: true,
|
||||
cwd,
|
||||
},
|
||||
rules: { workspacePath: workspaceRoot },
|
||||
workflows: { workspacePath: workspaceRoot },
|
||||
});
|
||||
const createCliUserInstructionService = (location: {
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}) =>
|
||||
createUserInstructionConfigService({
|
||||
skills: {
|
||||
workspacePath: location.workspaceRoot,
|
||||
includePluginSkills: true,
|
||||
cwd: location.cwd,
|
||||
},
|
||||
rules: { workspacePath: location.workspaceRoot },
|
||||
workflows: { workspacePath: location.workspaceRoot },
|
||||
});
|
||||
const userInstructionService = createMutableUserInstructionConfigService(
|
||||
createCliUserInstructionService({ cwd, workspaceRoot }),
|
||||
);
|
||||
await userInstructionService.start().catch(() => {});
|
||||
let userInstructionServiceDisposed = false;
|
||||
const stopUserInstructionService = () => {
|
||||
@@ -990,6 +998,16 @@ export async function runCli(): Promise<void> {
|
||||
(!process.stdin.isTTY && !args.interactive);
|
||||
const isInteractive = (args.interactive || !args.prompt) && !isHeadless;
|
||||
|
||||
// Computer-use is wired only into the interactive runtime, so any other
|
||||
// path yields a session with no `computer` tool. The model then reports
|
||||
// having no such tool, which reads like a backend fault rather than a
|
||||
// consequence of how cline was invoked.
|
||||
if (process.env.CLINE_COMPUTER_USE_PORT?.trim() && !isInteractive) {
|
||||
writeln(
|
||||
`${c.dim}[warn] CLINE_COMPUTER_USE_PORT is set, but computer use is only available in interactive mode, so the "computer" tool will not be registered for this run. Start cline without a prompt argument (and without --yolo/--zen/--output json) to use it.${c.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey && isOAuthProvider(provider) && !isHeadless && !isInteractive) {
|
||||
const oauthResult = await ensureOAuthProviderApiKey({
|
||||
providerId: provider,
|
||||
@@ -1206,6 +1224,9 @@ export async function runCli(): Promise<void> {
|
||||
await runInteractive(config, userInstructionService, resumeSessionId, {
|
||||
initialPrompt: args.prompt,
|
||||
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
|
||||
explicitSystemPrompt: args.systemPrompt,
|
||||
mutableUserInstructionService: userInstructionService,
|
||||
createUserInstructionService: createCliUserInstructionService,
|
||||
clineProviderSettings: initialClineProviderSettings,
|
||||
startupTarget,
|
||||
initialNotice,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
abortActiveRuntime,
|
||||
acquireAbortRejectionShield,
|
||||
cleanupActiveRuntime,
|
||||
clearAbortInProgress,
|
||||
isAbortInProgress,
|
||||
markAbortInProgress,
|
||||
setActiveRuntimeAbort,
|
||||
setActiveRuntimeCleanup,
|
||||
} from "./active-runtime";
|
||||
@@ -36,4 +40,26 @@ describe("active runtime hooks", () => {
|
||||
|
||||
expect(() => cleanupActiveRuntime()).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps abort rejection shielding active until overlapping aborts clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
markAbortInProgress();
|
||||
const releaseHelperAbort = acquireAbortRejectionShield();
|
||||
expect(isAbortInProgress()).toBe(true);
|
||||
|
||||
clearAbortInProgress();
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(isAbortInProgress()).toBe(true);
|
||||
|
||||
releaseHelperAbort();
|
||||
expect(isAbortInProgress()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(isAbortInProgress()).toBe(false);
|
||||
} finally {
|
||||
clearAbortInProgress();
|
||||
await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
let activeRuntimeAbort: (() => void) | undefined;
|
||||
let activeRuntimeCleanup: (() => void) | undefined;
|
||||
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortInProgress = false;
|
||||
let abortScopeCount = 0;
|
||||
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
|
||||
let activeRuntimeAbortRelease: (() => void) | undefined;
|
||||
|
||||
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
|
||||
activeRuntimeAbort = abortFn;
|
||||
@@ -35,35 +36,61 @@ export function cleanupActiveRuntime(): void {
|
||||
// correctly (returns finishReason:"aborted"), but orphan rejections from
|
||||
// the streaming layer or hub capability teardown surface as
|
||||
// unhandledRejections and would otherwise crash the CLI.
|
||||
export function acquireAbortRejectionShield(): () => void {
|
||||
abortScopeCount += 1;
|
||||
if (abortScopeCount === 1) {
|
||||
if (abortGraceTimer) {
|
||||
clearTimeout(abortGraceTimer);
|
||||
abortGraceTimer = undefined;
|
||||
}
|
||||
if (!savedRejectionListeners) {
|
||||
// Temporarily replace all unhandledRejection listeners with a
|
||||
// suppressing handler. AbortController.abort() causes orphan promise
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners(
|
||||
"unhandledRejection",
|
||||
) as Array<(...args: unknown[]) => void>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
releaseAbortRejectionShield();
|
||||
};
|
||||
}
|
||||
|
||||
export function markAbortInProgress(): void {
|
||||
if (abortInProgress) {
|
||||
return;
|
||||
}
|
||||
abortInProgress = true;
|
||||
if (abortGraceTimer) {
|
||||
clearTimeout(abortGraceTimer);
|
||||
abortGraceTimer = undefined;
|
||||
}
|
||||
// Temporarily replace all unhandledRejection listeners with a
|
||||
// suppressing handler. AbortController.abort() causes orphan promise
|
||||
// rejections in the LLM streaming layer that reach every registered
|
||||
// listener (including OpenTUI's error overlay). Swapping the listeners
|
||||
// is the only way to prevent them from surfacing to the user.
|
||||
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
process.on("unhandledRejection", (_reason, promise) => {
|
||||
promise.catch(() => {});
|
||||
});
|
||||
activeRuntimeAbortRelease ??= acquireAbortRejectionShield();
|
||||
}
|
||||
|
||||
export function clearAbortInProgress(): void {
|
||||
const release = activeRuntimeAbortRelease;
|
||||
activeRuntimeAbortRelease = undefined;
|
||||
release?.();
|
||||
}
|
||||
|
||||
function releaseAbortRejectionShield(): void {
|
||||
if (abortScopeCount === 0) {
|
||||
return;
|
||||
}
|
||||
abortScopeCount -= 1;
|
||||
if (abortScopeCount > 0) {
|
||||
return;
|
||||
}
|
||||
if (abortGraceTimer) {
|
||||
clearTimeout(abortGraceTimer);
|
||||
}
|
||||
abortGraceTimer = setTimeout(() => {
|
||||
abortInProgress = false;
|
||||
abortGraceTimer = undefined;
|
||||
if (savedRejectionListeners) {
|
||||
process.removeAllListeners("unhandledRejection");
|
||||
@@ -76,5 +103,5 @@ export function clearAbortInProgress(): void {
|
||||
}
|
||||
|
||||
export function isAbortInProgress(): boolean {
|
||||
return abortInProgress;
|
||||
return abortScopeCount > 0 || abortGraceTimer !== undefined;
|
||||
}
|
||||
|
||||
@@ -39,12 +39,15 @@ function makeState(config: Config): ChatCommandState {
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(): InteractiveChatCommandRuntime {
|
||||
function makeRuntime(): InteractiveChatCommandRuntime & {
|
||||
changeWorkingDirectory: (next: ChatCommandState) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
forkCurrentSession: vi.fn(async () => undefined),
|
||||
getActiveSessionId: vi.fn(() => "session-1"),
|
||||
resetForNewSession: vi.fn(async () => {}),
|
||||
restartEmpty: vi.fn(async () => {}),
|
||||
changeWorkingDirectory: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +65,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -90,6 +94,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -116,6 +121,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -149,6 +155,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
@@ -164,6 +171,72 @@ describe("runInteractiveChatCommand", () => {
|
||||
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("changes the runtime working directory before reporting /cd success", async () => {
|
||||
const config = makeConfig();
|
||||
const state = makeState(config);
|
||||
const runtime = makeRuntime();
|
||||
const target = process.cwd();
|
||||
state.cwd = "/tmp";
|
||||
state.workspaceRoot = "/tmp";
|
||||
vi.mocked(runtime.changeWorkingDirectory).mockImplementation(
|
||||
async (next) => {
|
||||
Object.assign(state, next);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runInteractiveChatCommand({
|
||||
prompt: `/cd ${target}`,
|
||||
enabled: true,
|
||||
config,
|
||||
host: chatCommandHost,
|
||||
chatCommandState: state,
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
expect(runtime.changeWorkingDirectory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: target }),
|
||||
);
|
||||
expect(state.cwd).toBe(target);
|
||||
expect(result).toMatchObject({
|
||||
handled: true,
|
||||
turnResult: { commandOutput: expect.stringContaining(`cwd=${target}`) },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not run /cd when the submission is queued behind an active turn", async () => {
|
||||
const config = makeConfig();
|
||||
const state = makeState(config);
|
||||
const runtime = makeRuntime();
|
||||
const target = process.cwd();
|
||||
state.cwd = "/tmp";
|
||||
state.workspaceRoot = "/tmp";
|
||||
|
||||
await expect(
|
||||
runInteractiveChatCommand({
|
||||
prompt: `/cd ${target}`,
|
||||
enabled: true,
|
||||
delivery: "queue",
|
||||
config,
|
||||
host: chatCommandHost,
|
||||
chatCommandState: state,
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Cannot change working directory while a turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
|
||||
expect(runtime.changeWorkingDirectory).not.toHaveBeenCalled();
|
||||
expect(state).toMatchObject({ cwd: "/tmp", workspaceRoot: "/tmp" });
|
||||
});
|
||||
|
||||
it("returns plugin command submit prompts as model input", async () => {
|
||||
const config = makeConfig();
|
||||
const runtime = makeRuntime();
|
||||
@@ -185,6 +258,7 @@ describe("runInteractiveChatCommand", () => {
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
changeWorkingDirectory: runtime.changeWorkingDirectory,
|
||||
stop: () => {},
|
||||
onCommandOutput,
|
||||
});
|
||||
|
||||
@@ -39,12 +39,14 @@ function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
|
||||
export async function runInteractiveChatCommand(input: {
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
delivery?: "queue" | "steer";
|
||||
config: Config;
|
||||
host: ChatCommandHost;
|
||||
chatCommandState: ChatCommandState;
|
||||
autoApproveAllRef: AutoApproveRef;
|
||||
setInteractiveAutoApprove: (enabled: boolean) => void;
|
||||
sessionRuntime: InteractiveChatCommandRuntime;
|
||||
changeWorkingDirectory: (next: ChatCommandState) => Promise<void>;
|
||||
stop: () => void;
|
||||
onCommandOutput?: (text: string) => void;
|
||||
}): Promise<InteractiveChatCommandResult> {
|
||||
@@ -74,10 +76,22 @@ export async function runInteractiveChatCommand(input: {
|
||||
autoApproveTools: input.autoApproveAllRef.current,
|
||||
}),
|
||||
setState: async (next) => {
|
||||
input.chatCommandState.enableTools = next.enableTools;
|
||||
input.chatCommandState.autoApproveTools = next.autoApproveTools;
|
||||
input.chatCommandState.cwd = next.cwd;
|
||||
input.chatCommandState.workspaceRoot = next.workspaceRoot;
|
||||
if (
|
||||
next.cwd !== input.chatCommandState.cwd ||
|
||||
next.workspaceRoot !== input.chatCommandState.workspaceRoot
|
||||
) {
|
||||
// Workspace resources and the replacement session change together at
|
||||
// an immediate submission boundary; deferred prompts cannot replay CLI
|
||||
// commands after the active turn finishes.
|
||||
if (input.delivery) {
|
||||
throw new Error(
|
||||
"Cannot change working directory while a turn is running. Wait for it to finish or abort it first.",
|
||||
);
|
||||
}
|
||||
await input.changeWorkingDirectory(next);
|
||||
} else {
|
||||
Object.assign(input.chatCommandState, next);
|
||||
}
|
||||
input.setInteractiveAutoApprove(next.autoApproveTools);
|
||||
},
|
||||
reply: async (text) => {
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
type AddressInfo,
|
||||
createServer,
|
||||
type Server,
|
||||
type Socket,
|
||||
} from "node:net";
|
||||
import type { AgentHooks, AgentResult, AgentToolContext } from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
createInteractiveComputerUser,
|
||||
resolveHelperModelId,
|
||||
withHelperReasoningControls,
|
||||
} from "./computer-user";
|
||||
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const releaseAbortRejectionShieldMock = vi.hoisted(() => vi.fn());
|
||||
const acquireAbortRejectionShieldMock = vi.hoisted(() =>
|
||||
vi.fn(() => releaseAbortRejectionShieldMock),
|
||||
);
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
acquireAbortRejectionShield: acquireAbortRejectionShieldMock,
|
||||
}));
|
||||
|
||||
const toolContext: AgentToolContext = {
|
||||
agentId: "driver-agent",
|
||||
conversationId: "driver-conversation",
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Stub qbt backend answering get_display_info, which tool construction
|
||||
* always performs (the backend is the sole source of truth for display
|
||||
* dimensions). Tracks sockets so teardown can force-close the tool's
|
||||
* internal client connection.
|
||||
*/
|
||||
function startStubBackend(): Promise<{
|
||||
server: Server;
|
||||
port: number;
|
||||
destroyConnections: () => void;
|
||||
}> {
|
||||
const sockets = new Set<Socket>();
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((socket: Socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
let buffer = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line.trim().length > 0) {
|
||||
const request = JSON.parse(line) as { id: number };
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
display: { widthPx: 1920, heightPx: 1080 },
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
resolve({
|
||||
server,
|
||||
port: address.port,
|
||||
destroyConnections: () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
cwd: "C:/work",
|
||||
workspaceRoot: "C:/work",
|
||||
} as Config;
|
||||
}
|
||||
|
||||
function makeSettings(settings: Record<string, unknown> | undefined) {
|
||||
return {
|
||||
getProviderSettings: () => settings as never,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult(overrides: Partial<AgentResult> = {}): AgentResult {
|
||||
return {
|
||||
text: "done",
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
...overrides,
|
||||
} as AgentResult;
|
||||
}
|
||||
|
||||
describe("createInteractiveComputerUser", () => {
|
||||
let server: Server | undefined;
|
||||
let destroyConnections: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
createCliCoreMock.mockReset();
|
||||
releaseAbortRejectionShieldMock.mockReset();
|
||||
acquireAbortRejectionShieldMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
destroyConnections?.();
|
||||
destroyConnections = undefined;
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
it("returns undefined when computer use is not enabled by env", async () => {
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
|
||||
notifyDriver: () => {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the Anthropic provider has no api key", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings(undefined),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes the driver tools when enabled and configured", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.driverTools.map((tool) => tool.name).sort()).toEqual([
|
||||
"computer_user_interrupt",
|
||||
"computer_user_message",
|
||||
"computer_user_restart",
|
||||
"computer_user_start",
|
||||
"computer_user_status",
|
||||
"computer_user_transcript",
|
||||
]);
|
||||
// The raw computer tool must not be among the driver's tools.
|
||||
expect(result?.driverTools.some((tool) => tool.name === "computer")).toBe(
|
||||
false,
|
||||
);
|
||||
await result?.dispose();
|
||||
});
|
||||
|
||||
it("adds the backend restart tool only when a launch command is configured", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
CLINE_COMPUTER_USE_BACKEND_COMMAND: "echo start-the-backend",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(
|
||||
result?.driverTools
|
||||
.map((tool) => tool.name)
|
||||
.includes("computer_user_restart_backend"),
|
||||
).toBe(true);
|
||||
await result?.dispose();
|
||||
});
|
||||
|
||||
it("keeps transcript session identities across helper replacement", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
const hooks: AgentHooks[] = [];
|
||||
createCliCoreMock.mockResolvedValue({
|
||||
start: vi.fn(async ({ config }: { config: { hooks: AgentHooks } }) => {
|
||||
hooks.push(config.hooks);
|
||||
return { sessionId: `helper-${hooks.length}` };
|
||||
}),
|
||||
send: vi.fn(async () => makeResult()),
|
||||
abort: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
|
||||
notifyDriver: () => {},
|
||||
env: { CLINE_COMPUTER_USE_PORT: String(started.port) },
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
if (!result) throw new Error("computer user was not configured");
|
||||
const tool = (name: string) => {
|
||||
const found = result.driverTools.find((tool) => tool.name === name);
|
||||
if (!found) throw new Error(`missing tool ${name}`);
|
||||
return found;
|
||||
};
|
||||
const recordMessage = (hook: AgentHooks, text: string) =>
|
||||
hook.onEvent?.({
|
||||
type: "message-added",
|
||||
snapshot: { agentId: "helper-agent" } as never,
|
||||
message: {
|
||||
id: text,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
createdAt: 0,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await tool("computer_user_start").execute({ task: "first" }, toolContext);
|
||||
await recordMessage(hooks[0], "first");
|
||||
await tool("computer_user_restart").execute({}, toolContext);
|
||||
await tool("computer_user_start").execute(
|
||||
{ task: "second" },
|
||||
toolContext,
|
||||
);
|
||||
await recordMessage(hooks[1], "second");
|
||||
await recordMessage(hooks[0], "late first");
|
||||
const transcript = await tool("computer_user_transcript").execute(
|
||||
{},
|
||||
toolContext,
|
||||
);
|
||||
expect(transcript).toMatchObject({
|
||||
entries: [
|
||||
{ sessionId: "helper-1", text: "first" },
|
||||
{ sessionId: "helper-2", text: "second" },
|
||||
{ sessionId: "helper-1", text: "late first" },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await result.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the helper with one moderate adaptive reasoning snapshot", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
const start = vi.fn(
|
||||
async (_input: {
|
||||
config: Record<string, unknown>;
|
||||
interactive: boolean;
|
||||
}) => ({ sessionId: "helper-session" }),
|
||||
);
|
||||
const send = vi.fn(() => new Promise(() => {}));
|
||||
createCliCoreMock.mockResolvedValue({
|
||||
start,
|
||||
send,
|
||||
abort: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
});
|
||||
const driverConfig = {
|
||||
...makeConfig(),
|
||||
thinking: true,
|
||||
reasoningEffort: "high" as const,
|
||||
};
|
||||
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: driverConfig,
|
||||
providerSettingsManager: makeSettings({
|
||||
provider: "anthropic",
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-5",
|
||||
client: "openai",
|
||||
protocol: "openai-responses",
|
||||
routingProviderId: "openai-native",
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort: "low",
|
||||
budgetTokens: 8192,
|
||||
},
|
||||
}),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
CLINE_COMPUTER_USER_MODEL: "anthropic/claude-sonnet-5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const startTool = result?.driverTools.find(
|
||||
(tool) => tool.name === "computer_user_start",
|
||||
);
|
||||
|
||||
await startTool?.execute({ task: "inspect the desktop" }, toolContext);
|
||||
|
||||
expect(start).toHaveBeenCalledWith({
|
||||
interactive: true,
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-5",
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
providerConfig: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-5",
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
clientType: undefined,
|
||||
routingProviderId: undefined,
|
||||
thinkingBudgetTokens: undefined,
|
||||
knownModels: expect.objectContaining({
|
||||
"claude-sonnet-5": expect.objectContaining({
|
||||
reasoningOptions: [
|
||||
{
|
||||
type: "effort",
|
||||
values: ["low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(start.mock.calls[0]?.[0]?.config).not.toHaveProperty(
|
||||
"thinkingBudgetTokens",
|
||||
);
|
||||
expect(driverConfig).toMatchObject({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
await result?.dispose();
|
||||
});
|
||||
|
||||
it("shields abort rejections until the helper run is quiescent", async () => {
|
||||
const started = await startStubBackend();
|
||||
server = started.server;
|
||||
destroyConnections = started.destroyConnections;
|
||||
let resolveSend: ((result: AgentResult) => void) | undefined;
|
||||
const send = vi.fn(
|
||||
() =>
|
||||
new Promise<AgentResult>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
const abort = vi.fn(async () => {});
|
||||
createCliCoreMock.mockResolvedValue({
|
||||
start: vi.fn(async () => ({ sessionId: "helper-session" })),
|
||||
send,
|
||||
abort,
|
||||
stop: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await createInteractiveComputerUser({
|
||||
config: makeConfig(),
|
||||
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
|
||||
notifyDriver: () => {},
|
||||
env: {
|
||||
CLINE_COMPUTER_USE_PORT: String(started.port),
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const byName = new Map(
|
||||
result?.driverTools.map((tool) => [tool.name, tool]) ?? [],
|
||||
);
|
||||
await byName
|
||||
.get("computer_user_start")
|
||||
?.execute({ task: "inspect the desktop" }, toolContext);
|
||||
|
||||
let stopped = false;
|
||||
const interruption = byName
|
||||
.get("computer_user_interrupt")
|
||||
?.execute({ reason: "no progress" }, toolContext) as Promise<unknown>;
|
||||
const observedInterruption = interruption.then((output) => {
|
||||
stopped = true;
|
||||
return output;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(abort).toHaveBeenCalledWith(
|
||||
"helper-session",
|
||||
expect.objectContaining({ message: "no progress" }),
|
||||
);
|
||||
});
|
||||
expect(acquireAbortRejectionShieldMock).toHaveBeenCalledTimes(1);
|
||||
expect(releaseAbortRejectionShieldMock).not.toHaveBeenCalled();
|
||||
expect(stopped).toBe(false);
|
||||
|
||||
resolveSend?.(makeResult({ finishReason: "aborted" }));
|
||||
await expect(observedInterruption).resolves.toMatchObject({
|
||||
status: "stopped",
|
||||
});
|
||||
expect(releaseAbortRejectionShieldMock).toHaveBeenCalledTimes(1);
|
||||
await result?.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withHelperReasoningControls", () => {
|
||||
it("declares adaptive effort controls for the helper model", () => {
|
||||
const result = withHelperReasoningControls(undefined, "claude-sonnet-5");
|
||||
expect(result["claude-sonnet-5"]).toEqual({
|
||||
id: "claude-sonnet-5",
|
||||
reasoningOptions: [
|
||||
{
|
||||
type: "effort",
|
||||
values: ["low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves other catalog entries and the helper model's own facts", () => {
|
||||
const result = withHelperReasoningControls(
|
||||
{
|
||||
"claude-sonnet-5": {
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
"claude-opus-4-7": { id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
},
|
||||
"claude-sonnet-5",
|
||||
);
|
||||
expect(result["claude-sonnet-5"]).toMatchObject({
|
||||
name: "Claude Sonnet 5",
|
||||
contextWindow: 1000000,
|
||||
});
|
||||
expect(result["claude-opus-4-7"]).toEqual({
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHelperModelId", () => {
|
||||
it("prefers CLINE_COMPUTER_USER_MODEL over saved provider model", () => {
|
||||
expect(
|
||||
resolveHelperModelId({ model: "claude-sonnet-4-6" }, {
|
||||
CLINE_COMPUTER_USER_MODEL: "claude-opus-4-7",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("removes the redundant namespace for the direct Anthropic provider", () => {
|
||||
expect(
|
||||
resolveHelperModelId(undefined, {
|
||||
CLINE_COMPUTER_USER_MODEL: "anthropic/claude-sonnet-5",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("claude-sonnet-5");
|
||||
});
|
||||
|
||||
it("falls back to the Anthropic provider entry's saved model", () => {
|
||||
expect(
|
||||
resolveHelperModelId(
|
||||
{ model: "claude-haiku-4-5" },
|
||||
{} as NodeJS.ProcessEnv,
|
||||
),
|
||||
).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("defaults when neither env nor settings specify a model", () => {
|
||||
expect(resolveHelperModelId(undefined, {} as NodeJS.ProcessEnv)).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
expect(
|
||||
resolveHelperModelId({ model: " " }, {
|
||||
CLINE_COMPUTER_USER_MODEL: " ",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
type AgentHooks,
|
||||
type ClineCore,
|
||||
COMPUTER_USER_SYSTEM_PROMPT,
|
||||
ComputerBackendRestart,
|
||||
ComputerTaskArtifactRecorder,
|
||||
ComputerUseClient,
|
||||
ComputerUserCoordinator,
|
||||
ComputerUserTranscriptLog,
|
||||
createComputerUserCollaborationTools,
|
||||
createComputerUserDriverTools,
|
||||
createComputerUseTool,
|
||||
createJournalEventSink,
|
||||
createTranscriptRecordingHooks,
|
||||
type ProviderSettingsManager,
|
||||
resolveComputerUseBackendCommandFromEnv,
|
||||
resolveComputerUseTargetFromEnv,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import type { AgentTool, ModelInfo, ModelReasoningOption } from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
import { createCliCore } from "../../session/session";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { acquireAbortRejectionShield } from "../active-runtime";
|
||||
|
||||
/**
|
||||
* CLI host integration for the asynchronous computer user.
|
||||
*
|
||||
* The driver session gets four `computer_user_*` tools; the helper runs as a
|
||||
* dedicated interactive ClineCore session on the Anthropic provider (the
|
||||
* computer-use beta header requires the direct provider — see qwanban's
|
||||
* README). Enabled by the same `CLINE_COMPUTER_USE_PORT` opt-in as the raw
|
||||
* `computer` tool; when the coordinator is active the driver deliberately
|
||||
* does NOT get the raw tool, so all GUI work flows through the helper.
|
||||
*
|
||||
* Helper consistency boundary: provider, credentials, reasoning, tool
|
||||
* inventory, and prompt are resolved here, once, when the runtime starts.
|
||||
* Changing them requires a new CLI session.
|
||||
*/
|
||||
|
||||
const HELPER_PROVIDER_ID = "anthropic";
|
||||
const HELPER_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
|
||||
const HELPER_MODEL_ENV_VAR = "CLINE_COMPUTER_USER_MODEL";
|
||||
const HELPER_REASONING = {
|
||||
thinking: true,
|
||||
reasoningEffort: "medium" as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reasoning controls declared for the helper's model, in the models.dev
|
||||
* shape the Anthropic provider routing reads. The bundled model catalog
|
||||
* ships without `reasoningOptions`, which the routing treats as an
|
||||
* unlisted model with manual-only thinking and encodes as
|
||||
* `thinking.type.enabled`; current Claude models reject that shape and
|
||||
* require `thinking.type.adaptive` with an effort level. Declaring the
|
||||
* controls here keeps the helper on the adaptive wire shape regardless of
|
||||
* catalog state.
|
||||
*/
|
||||
const HELPER_MODEL_REASONING_OPTIONS: ModelReasoningOption[] = [
|
||||
{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] },
|
||||
];
|
||||
|
||||
function toDirectAnthropicModelId(modelId: string): string {
|
||||
const directProviderPrefix = `${HELPER_PROVIDER_ID}/`;
|
||||
return modelId.startsWith(directProviderPrefix)
|
||||
? modelId.slice(directProviderPrefix.length)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the provider config's model catalog with the helper's reasoning
|
||||
* controls declared for the helper model, preserving every other entry.
|
||||
*/
|
||||
export function withHelperReasoningControls(
|
||||
knownModels: Record<string, ModelInfo> | undefined,
|
||||
modelId: string,
|
||||
): Record<string, ModelInfo> {
|
||||
return {
|
||||
...knownModels,
|
||||
[modelId]: {
|
||||
...knownModels?.[modelId],
|
||||
id: modelId,
|
||||
reasoningOptions: HELPER_MODEL_REASONING_OPTIONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the helper's Anthropic model id. The helper's model is chosen
|
||||
* independently of the driver's: CLINE_COMPUTER_USER_MODEL wins, then the
|
||||
* Anthropic provider entry's saved `model`, then the default. The provider
|
||||
* is always the direct `anthropic` provider — the computer-use beta header
|
||||
* is only sent on that wire target, so Anthropic models reached through
|
||||
* other providers (cline, openrouter, bedrock) would lack the extended
|
||||
* action set.
|
||||
*/
|
||||
export function resolveHelperModelId(
|
||||
helperSettings: { model?: unknown } | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
const fromEnv = env[HELPER_MODEL_ENV_VAR]?.trim();
|
||||
if (fromEnv) {
|
||||
return toDirectAnthropicModelId(fromEnv);
|
||||
}
|
||||
if (
|
||||
typeof helperSettings?.model === "string" &&
|
||||
helperSettings.model.trim()
|
||||
) {
|
||||
return toDirectAnthropicModelId(helperSettings.model.trim());
|
||||
}
|
||||
return HELPER_DEFAULT_MODEL_ID;
|
||||
}
|
||||
|
||||
export interface InteractiveComputerUser {
|
||||
driverTools: AgentTool[];
|
||||
/**
|
||||
* Hooks layer to merge into the driver session's config: records the
|
||||
* driver's transcript and run status to the backend journal alongside
|
||||
* the helper's, so the observatory can flip between both timelines.
|
||||
*/
|
||||
driverRecordingHooks: AgentHooks;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createInteractiveComputerUser(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: Pick<ProviderSettingsManager, "getProviderSettings">;
|
||||
/**
|
||||
* Injects a prompt into the driver's conversation. Must resolve the
|
||||
* driver session id at call time (session rebuilds change it), which
|
||||
* `sessionRuntime.sendCurrentTurn` does.
|
||||
*/
|
||||
notifyDriver: (prompt: string, delivery: "queue" | "steer") => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<InteractiveComputerUser | undefined> {
|
||||
// Check the local precondition (credentials) before dialing the backend:
|
||||
// tool construction queries the backend for display info and holds a
|
||||
// socket, which would be wasted if the helper cannot be configured.
|
||||
const helperSettings =
|
||||
input.providerSettingsManager.getProviderSettings(HELPER_PROVIDER_ID);
|
||||
const helperApiKey =
|
||||
typeof helperSettings?.apiKey === "string" ? helperSettings.apiKey : "";
|
||||
if (!helperApiKey) {
|
||||
// No silent fallback to the driver's credentials: the helper requires
|
||||
// the Anthropic provider's own configuration.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const target = resolveComputerUseTargetFromEnv(input.env ?? process.env);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// One backend client shared by the computer tool and the observability
|
||||
// publisher. The backend serves a single agent connection at a time, so
|
||||
// splitting these across two sockets would make one of them dead.
|
||||
//
|
||||
// No client-side action observer: the backend journals every computer
|
||||
// action (with its screenshot) as it executes it, so recording actions
|
||||
// here too would give the journal two producers for one event type.
|
||||
const computerClient = new ComputerUseClient(target);
|
||||
const recorder = new ComputerTaskArtifactRecorder(
|
||||
`task_${nanoid(10)}`,
|
||||
createJournalEventSink(computerClient),
|
||||
);
|
||||
|
||||
const computerTool = await createComputerUseTool({
|
||||
...target,
|
||||
client: computerClient,
|
||||
});
|
||||
|
||||
// In-process tail of the helper's transcript. The driver's
|
||||
// computer_user_transcript tool reads it, so peeking works even while
|
||||
// the backend is down; the tee shares the recording hooks' reduction, so
|
||||
// what the tool shows is identical to what the observatory journals.
|
||||
const transcriptLog = new ComputerUserTranscriptLog();
|
||||
const backendRestart = (() => {
|
||||
const command = resolveComputerUseBackendCommandFromEnv(
|
||||
input.env ?? process.env,
|
||||
);
|
||||
return command
|
||||
? new ComputerBackendRestart({
|
||||
...target,
|
||||
command,
|
||||
client: computerClient,
|
||||
})
|
||||
: undefined;
|
||||
})();
|
||||
|
||||
const helperModelId = resolveHelperModelId(
|
||||
helperSettings,
|
||||
input.env ?? process.env,
|
||||
);
|
||||
// Helper model and reasoning settings become effective together when this
|
||||
// session is created. Keep the provider config and session config derived
|
||||
// from this snapshot so saved manual thinking budgets cannot conflict with
|
||||
// adaptive thinking on current Claude models. The model's reasoning
|
||||
// controls are declared explicitly: the bundled catalog ships without
|
||||
// them, and without them the Anthropic routing falls back to the manual
|
||||
// thinking shape those models reject.
|
||||
const baseProviderConfig = toProviderConfig({
|
||||
...helperSettings,
|
||||
provider: HELPER_PROVIDER_ID,
|
||||
model: helperModelId,
|
||||
client: undefined,
|
||||
protocol: undefined,
|
||||
routingProviderId: undefined,
|
||||
reasoning: {
|
||||
enabled: HELPER_REASONING.thinking,
|
||||
effort: HELPER_REASONING.reasoningEffort,
|
||||
},
|
||||
});
|
||||
const helperProviderConfig = {
|
||||
...baseProviderConfig,
|
||||
clientType: undefined,
|
||||
routingProviderId: undefined,
|
||||
thinkingBudgetTokens: undefined,
|
||||
knownModels: withHelperReasoningControls(
|
||||
baseProviderConfig.knownModels,
|
||||
helperModelId,
|
||||
),
|
||||
};
|
||||
|
||||
// The helper config and the coordinator reference each other (the
|
||||
// collaboration tools call back into the coordinator). Break the cycle
|
||||
// with one shared extraTools array: the coordinator captures the config
|
||||
// object now; the tools are pushed into the same array below, before any
|
||||
// session can start.
|
||||
const helperExtraTools: AgentTool[] = [computerTool];
|
||||
const helperConfig = {
|
||||
providerId: helperProviderConfig.providerId,
|
||||
modelId: helperProviderConfig.modelId,
|
||||
apiKey: helperProviderConfig.apiKey,
|
||||
baseUrl: helperProviderConfig.baseUrl,
|
||||
headers: helperProviderConfig.headers,
|
||||
knownModels: helperProviderConfig.knownModels,
|
||||
providerConfig: helperProviderConfig,
|
||||
...HELPER_REASONING,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot?.trim() || input.config.cwd,
|
||||
mode: "act" as const,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
pluginPaths: [],
|
||||
systemPrompt: COMPUTER_USER_SYSTEM_PROMPT,
|
||||
extraTools: helperExtraTools,
|
||||
toolPolicies: {
|
||||
// Questions and completion go to the driver through the
|
||||
// collaboration tools, never to a human or generic completion.
|
||||
ask_question: { enabled: false },
|
||||
submit_and_exit: { enabled: false },
|
||||
},
|
||||
// The helper's terminal tools are ask_driver/finish_computer_task
|
||||
// (extraTools with completesRun). Require them explicitly: the
|
||||
// builder's inference only recognizes submit_and_exit, which is
|
||||
// disabled above, and a run that ends in free-form text would leave
|
||||
// the driver waiting with no report.
|
||||
completionPolicy: { requireCompletionTool: true },
|
||||
};
|
||||
|
||||
// Lazy: the helper ClineCore spawns only when the driver first delegates.
|
||||
// forceLocalBackend keeps the helper in this process, where the
|
||||
// computer-use backend's loopback socket is reachable — a hub daemon may
|
||||
// run on a different machine from the controlled display.
|
||||
let helperCorePromise: Promise<ClineCore> | undefined;
|
||||
let activeHelperSend: Promise<unknown> | undefined;
|
||||
const getHelperCore = () => {
|
||||
helperCorePromise ??= createCliCore({
|
||||
forceLocalBackend: true,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
logger: input.config.logger,
|
||||
}).catch((error) => {
|
||||
helperCorePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return helperCorePromise;
|
||||
};
|
||||
|
||||
const coordinator = new ComputerUserCoordinator({
|
||||
host: {
|
||||
start: async (startInput) => {
|
||||
// Each session owns its recording source, so late events from a
|
||||
// stopped helper cannot be relabelled as its replacement's work.
|
||||
const source = {
|
||||
kind: "computer_user" as const,
|
||||
sessionId: undefined as string | undefined,
|
||||
};
|
||||
const started = await (await getHelperCore()).start({
|
||||
config: {
|
||||
...startInput.config,
|
||||
hooks: createTranscriptRecordingHooks(recorder, source, (event) =>
|
||||
transcriptLog.append(event),
|
||||
),
|
||||
} as never,
|
||||
interactive: startInput.interactive,
|
||||
});
|
||||
source.sessionId = started.sessionId;
|
||||
return started;
|
||||
},
|
||||
send: async (sendInput) => {
|
||||
const send = (await getHelperCore()).send(sendInput);
|
||||
if (sendInput.delivery === "steer") {
|
||||
return await send;
|
||||
}
|
||||
activeHelperSend = send;
|
||||
try {
|
||||
return await send;
|
||||
} finally {
|
||||
if (activeHelperSend === send) {
|
||||
activeHelperSend = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
abort: async (sessionId, reason) => {
|
||||
const releaseAbortShield = acquireAbortRejectionShield();
|
||||
try {
|
||||
await (await getHelperCore()).abort(sessionId, reason);
|
||||
} catch (error) {
|
||||
releaseAbortShield();
|
||||
throw error;
|
||||
}
|
||||
const abortedSend = activeHelperSend;
|
||||
if (!abortedSend) {
|
||||
releaseAbortShield();
|
||||
return;
|
||||
}
|
||||
// The coordinator owns waiting for this run to settle. The adapter
|
||||
// only keeps expected provider cancellation rejections shielded for
|
||||
// the same interval, without making disposal wait on host teardown.
|
||||
void abortedSend.finally(releaseAbortShield).catch(() => {});
|
||||
},
|
||||
stop: async (sessionId) => (await getHelperCore()).stop(sessionId),
|
||||
},
|
||||
helperConfig,
|
||||
notifyDriver: ({ prompt, delivery }) =>
|
||||
input.notifyDriver(prompt, delivery),
|
||||
recorder,
|
||||
transcriptLog,
|
||||
});
|
||||
helperExtraTools.push(...createComputerUserCollaborationTools(coordinator));
|
||||
|
||||
return {
|
||||
driverTools: createComputerUserDriverTools(coordinator, {
|
||||
backendRestart,
|
||||
}),
|
||||
driverRecordingHooks: createTranscriptRecordingHooks(recorder, {
|
||||
kind: "driver",
|
||||
}),
|
||||
dispose: async () => {
|
||||
await coordinator.dispose().catch(() => {});
|
||||
if (helperCorePromise) {
|
||||
const core = await helperCorePromise.catch(() => undefined);
|
||||
await core?.dispose().catch(() => {});
|
||||
}
|
||||
// Push any queued journal publishes out before dropping the
|
||||
// backend connection.
|
||||
await recorder.flush().catch(() => {});
|
||||
computerClient.close();
|
||||
// Release a backend this process spawned; a backend someone else
|
||||
// owns is left running.
|
||||
await backendRestart?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -270,4 +270,37 @@ describe("applyInteractiveModeConfig", () => {
|
||||
expect(config.extraTools).toEqual([]);
|
||||
expect(config.systemPrompt).toBe("system prompt for act");
|
||||
});
|
||||
|
||||
it("keeps persistent extra tools across plan/act switches", async () => {
|
||||
const config = makeConfig();
|
||||
const computerUserTool = {
|
||||
...switchToActModeTool,
|
||||
name: "computer_user_start",
|
||||
};
|
||||
const persistentExtraTools = [computerUserTool];
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "plan",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
expect(config.extraTools).toEqual([switchToActModeTool, computerUserTool]);
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "act",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
expect(config.extraTools).toEqual([computerUserTool]);
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "plan",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
expect(config.extraTools).toEqual([switchToActModeTool, computerUserTool]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,14 +115,30 @@ export {
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
/**
|
||||
* Builds the extraTools list for an interactive mode. The single derivation
|
||||
* used both at startup and on every mode switch, so mode-independent tools
|
||||
* (e.g. the computer-user tools) cannot be silently dropped by a switch.
|
||||
*/
|
||||
export function buildInteractiveExtraTools(input: {
|
||||
mode: InteractiveUiMode;
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
persistentExtraTools?: NonNullable<Config["extraTools"]>;
|
||||
}): NonNullable<Config["extraTools"]> {
|
||||
return [
|
||||
...(input.mode === "plan" ? [input.switchToActModeTool] : []),
|
||||
...(input.persistentExtraTools ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
persistentExtraTools?: NonNullable<Config["extraTools"]>;
|
||||
}): Promise<void> {
|
||||
input.config.mode = input.mode;
|
||||
input.config.extraTools =
|
||||
input.mode === "plan" ? [input.switchToActModeTool] : [];
|
||||
input.config.extraTools = buildInteractiveExtraTools(input);
|
||||
input.config.systemPrompt = await resolveSystemPrompt({
|
||||
cwd: input.config.cwd,
|
||||
providerId: input.config.providerId,
|
||||
|
||||
@@ -22,6 +22,7 @@ const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
const resolveSystemPromptMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
@@ -47,6 +48,10 @@ vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: resolveSystemPromptMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
@@ -233,10 +238,12 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
resolveSystemPromptMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
resolveSystemPromptMock.mockResolvedValue("rebuilt system prompt");
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
@@ -587,6 +594,107 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("restarts the active session with one working-directory snapshot", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
const state = createChatCommandState(config);
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
explicitSystemPrompt: "custom prompt",
|
||||
chatCommandState: state,
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.changeWorkingDirectory({
|
||||
...state,
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
});
|
||||
|
||||
expect(resolveSystemPromptMock).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/next-project",
|
||||
explicitSystemPrompt: "custom prompt",
|
||||
providerId: "anthropic",
|
||||
mode: "act",
|
||||
});
|
||||
expect(config).toMatchObject({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
systemPrompt: "rebuilt system prompt",
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
});
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
systemPrompt: "rebuilt system prompt",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createRuntimeHooksMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the previous working-directory snapshot when restart fails", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
const state = createChatCommandState(config);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
manager.start.mockRejectedValueOnce(new Error("replacement failed"));
|
||||
|
||||
await expect(
|
||||
runtime.changeWorkingDirectory({
|
||||
...state,
|
||||
cwd: "/tmp/failed-project",
|
||||
workspaceRoot: "/tmp/failed-project",
|
||||
}),
|
||||
).rejects.toThrow("replacement failed");
|
||||
expect(config).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(3);
|
||||
expect(manager.start.mock.calls[2]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { basename } from "node:path";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type CoreSettingsToggleInput,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
mergeAgentHooks,
|
||||
type PendingPromptMutationResult,
|
||||
type ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
@@ -31,6 +33,7 @@ import { setActiveCliSession } from "../../utils/output";
|
||||
import { loadInteractiveResumeMessages } from "../../utils/resume";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { markAbortInProgress } from "../active-runtime";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
@@ -97,6 +100,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
explicitSystemPrompt?: string;
|
||||
resumeSessionId?: string;
|
||||
chatCommandState: ChatCommandState;
|
||||
requestToolApproval: (
|
||||
@@ -106,6 +110,17 @@ export function createInteractiveSessionRuntime(input: {
|
||||
askQuestionRef: AskQuestionRef;
|
||||
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
/**
|
||||
* Mode-independent extra tools (e.g. the computer-user tools) that must
|
||||
* survive plan/act switches. Rebuilt into config.extraTools on every
|
||||
* mode change alongside the mode-dependent switch tool.
|
||||
*/
|
||||
persistentExtraTools?: NonNullable<Config["extraTools"]>;
|
||||
/**
|
||||
* Host-supplied hooks layer (e.g. computer-use transcript recording)
|
||||
* merged after the runtime's own hooks on every session build.
|
||||
*/
|
||||
extraAgentHooks?: AgentHooks;
|
||||
onAgentEvent: (event: AgentEvent) => void;
|
||||
onTeamEvent: (event: TeamEvent) => void;
|
||||
onPendingPrompts: (event: PendingPromptSnapshot) => void;
|
||||
@@ -130,6 +145,20 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
const createWorkspaceRuntimeHooks = (
|
||||
manager: CliCore,
|
||||
workspace: Pick<ChatCommandState, "cwd" | "workspaceRoot">,
|
||||
): RuntimeHooks =>
|
||||
createRuntimeHooks({
|
||||
verbose: input.config.verbose,
|
||||
yolo: input.config.mode === "yolo",
|
||||
cwd: workspace.cwd,
|
||||
workspaceRoot: workspace.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.ingestHookEvent(payload);
|
||||
},
|
||||
});
|
||||
|
||||
const clearActiveSession = (): void => {
|
||||
activeSessionId = "";
|
||||
setActiveCliSession(undefined);
|
||||
@@ -179,15 +208,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
throw new Error("interactive runtime shutdown requested");
|
||||
}
|
||||
sessionManager = manager;
|
||||
runtimeHooks = createRuntimeHooks({
|
||||
verbose: input.config.verbose,
|
||||
yolo: input.config.mode === "yolo",
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.ingestHookEvent(payload);
|
||||
},
|
||||
});
|
||||
runtimeHooks = createWorkspaceRuntimeHooks(manager, input.chatCommandState);
|
||||
unsubscribeAgent = subscribeToAgentEvents(manager, input.onAgentEvent);
|
||||
unsubscribePendingPrompts = subscribeToPendingPromptEvents(manager, {
|
||||
onPendingPrompts: input.onPendingPrompts,
|
||||
@@ -201,7 +222,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
throw new Error("interactive runtime hooks are unavailable");
|
||||
}
|
||||
const hooks = withInteractiveApprovalPolicyHook(
|
||||
runtimeHooks.hooks,
|
||||
mergeAgentHooks([runtimeHooks.hooks, input.extraAgentHooks]),
|
||||
input.resolveToolPolicy,
|
||||
);
|
||||
return buildInteractiveSessionConfig({
|
||||
@@ -441,14 +462,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages: MessageWithMetadata[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
options?: { preserveSessionId?: boolean; sessionId?: string },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
const reuseSessionId =
|
||||
options?.sessionId ??
|
||||
(options?.preserveSessionId ? activeSessionId || undefined : undefined);
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
@@ -511,6 +532,99 @@ export function createInteractiveSessionRuntime(input: {
|
||||
);
|
||||
};
|
||||
|
||||
const changeWorkingDirectory = async (
|
||||
next: ChatCommandState,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
if (!manager) {
|
||||
throw new Error("interactive session manager is unavailable");
|
||||
}
|
||||
const sourceSessionId = activeSessionId;
|
||||
|
||||
const [{ messages, status }, compactionState, systemPrompt] =
|
||||
await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
resolveSystemPrompt({
|
||||
cwd: next.cwd,
|
||||
explicitSystemPrompt: input.explicitSystemPrompt,
|
||||
providerId: input.config.providerId,
|
||||
mode: input.config.mode,
|
||||
}),
|
||||
]);
|
||||
if (status !== "read" || activeSessionId !== sourceSessionId) {
|
||||
throw new Error("Working directory changed concurrently. Try /cd again.");
|
||||
}
|
||||
|
||||
const previousState = { ...input.chatCommandState };
|
||||
const previousSessionId = activeSessionId;
|
||||
const previousConfig = {
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
systemPrompt: input.config.systemPrompt,
|
||||
extensionContext: input.config.extensionContext,
|
||||
};
|
||||
const previousRuntimeHooks = runtimeHooks;
|
||||
const nextRuntimeHooks = createWorkspaceRuntimeHooks(manager, next);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
const initialCompactionState = projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// The directory becomes effective as one snapshot for the replacement
|
||||
// session. A concurrent ensureReady() waits on the restart barrier.
|
||||
Object.assign(input.chatCommandState, next);
|
||||
input.config.cwd = next.cwd;
|
||||
input.config.workspaceRoot = next.workspaceRoot;
|
||||
input.config.systemPrompt = systemPrompt;
|
||||
if (input.config.extensionContext?.workspace) {
|
||||
input.config.extensionContext = {
|
||||
...input.config.extensionContext,
|
||||
workspace: {
|
||||
...input.config.extensionContext.workspace,
|
||||
rootPath: next.workspaceRoot,
|
||||
cwd: next.cwd,
|
||||
workspaceName: basename(next.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
runtimeHooks = nextRuntimeHooks;
|
||||
|
||||
try {
|
||||
await restartWithMessages(messages, undefined, initialCompactionState, {
|
||||
preserveSessionId: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Object.assign(input.chatCommandState, previousState);
|
||||
input.config.cwd = previousConfig.cwd;
|
||||
input.config.workspaceRoot = previousConfig.workspaceRoot;
|
||||
input.config.systemPrompt = previousConfig.systemPrompt;
|
||||
input.config.extensionContext = previousConfig.extensionContext;
|
||||
runtimeHooks = previousRuntimeHooks;
|
||||
await nextRuntimeHooks.shutdown().catch(() => {});
|
||||
try {
|
||||
await restartWithMessages(messages, undefined, initialCompactionState, {
|
||||
sessionId: previousSessionId || undefined,
|
||||
});
|
||||
} catch (recoveryError) {
|
||||
throw new AggregateError(
|
||||
[error, recoveryError],
|
||||
"Working directory change failed, and the previous session could not be restored.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await previousRuntimeHooks?.shutdown().catch(() => {});
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
@@ -547,6 +661,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
config: input.config,
|
||||
mode,
|
||||
switchToActModeTool: input.switchToActModeTool,
|
||||
persistentExtraTools: input.persistentExtraTools,
|
||||
});
|
||||
await restartWithCurrentMessages();
|
||||
};
|
||||
@@ -910,6 +1025,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
changeWorkingDirectory,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createChatCommandHost } from "../../utils/chat-commands";
|
||||
import { createMutableUserInstructionConfigService } from "../../utils/mutable-user-instruction-service";
|
||||
import type { WorkspaceChatCommandHostResult } from "../../utils/plugin-chat-commands";
|
||||
import { createInteractiveWorkspaceResources } from "./workspace-resources";
|
||||
|
||||
describe("interactive workspace resources", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function createWorkspace(commandName: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cli-workspace-resources-"));
|
||||
tempRoots.push(root);
|
||||
const workflows = join(root, "workflows");
|
||||
await mkdir(workflows, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflows, `${commandName}.md`),
|
||||
`---\nname: ${commandName}\n---\nRun ${commandName}.`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
function createInstructionService(cwd: string): UserInstructionConfigService {
|
||||
return createUserInstructionConfigService({
|
||||
skills: { directories: [] },
|
||||
rules: { directories: [] },
|
||||
workflows: { directories: [join(cwd, "workflows")] },
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginResult(
|
||||
commandName: string,
|
||||
shutdown = vi.fn(async () => {}),
|
||||
): WorkspaceChatCommandHostResult {
|
||||
return {
|
||||
host: createChatCommandHost().register("command", {
|
||||
names: [`/${commandName}`],
|
||||
run: async (_parsed, context) => {
|
||||
await context.reply(commandName);
|
||||
},
|
||||
}),
|
||||
pluginSlashCommands: [{ name: commandName }],
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
|
||||
it("commits workflow expansion and plugin commands as one workspace snapshot", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
const onCommandsChanged = vi.fn();
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
|
||||
createPluginCommands: async ({ cwd }) =>
|
||||
createPluginResult(cwd === workspaceA ? "plugin-a" : "plugin-b"),
|
||||
onCommandsChanged,
|
||||
});
|
||||
|
||||
await resources.loadPluginSlashCommands();
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"Run workflow-a.",
|
||||
);
|
||||
expect(resources.getCommandSnapshot().pluginSlashCommands).toEqual([
|
||||
expect.objectContaining({ name: "plugin-a" }),
|
||||
]);
|
||||
|
||||
const applySessionChange = vi.fn(async () => {});
|
||||
await resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
applySessionChange,
|
||||
);
|
||||
|
||||
expect(applySessionChange).toHaveBeenCalledOnce();
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"/workflow-a",
|
||||
);
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-b")).toBe(
|
||||
"Run workflow-b.",
|
||||
);
|
||||
expect(onCommandsChanged).toHaveBeenLastCalledWith({
|
||||
workflowSlashCommands: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "workflow-b" }),
|
||||
]),
|
||||
pluginSlashCommands: [expect.objectContaining({ name: "plugin-b" })],
|
||||
});
|
||||
expect(
|
||||
onCommandsChanged.mock.calls
|
||||
.at(-1)?.[0]
|
||||
.workflowSlashCommands.map((command: { name: string }) => command.name),
|
||||
).not.toContain("workflow-a");
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
|
||||
it("keeps the previous workspace active when the agent session transition fails", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
const nextPluginShutdown = vi.fn(async () => {});
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
|
||||
createPluginCommands: async () =>
|
||||
createPluginResult("plugin-b", nextPluginShutdown),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
async () => {
|
||||
throw new Error("session restart failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("session restart failed");
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"Run workflow-a.",
|
||||
);
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-b")).toBe(
|
||||
"/workflow-b",
|
||||
);
|
||||
expect(nextPluginShutdown).toHaveBeenCalledOnce();
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
|
||||
it("rejects incompatible instruction services before changing the agent session", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
const incompatibleService = createInstructionService(workspaceB);
|
||||
incompatibleService.createSkillsExecutor = undefined;
|
||||
const applySessionChange = vi.fn(async () => {});
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: () => incompatibleService,
|
||||
createPluginCommands: async () => createPluginResult("plugin-b"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
applySessionChange,
|
||||
),
|
||||
).rejects.toThrow("incompatible skills capability");
|
||||
expect(applySessionChange).not.toHaveBeenCalled();
|
||||
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
|
||||
"Run workflow-a.",
|
||||
);
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
|
||||
it("does not let a stale plugin load replace a newer workspace", async () => {
|
||||
const workspaceA = await createWorkspace("workflow-a");
|
||||
const workspaceB = await createWorkspace("workflow-b");
|
||||
const initialService = createInstructionService(workspaceA);
|
||||
await initialService.start();
|
||||
const mutableService =
|
||||
createMutableUserInstructionConfigService(initialService);
|
||||
let resolveStaleLoad:
|
||||
| ((value: WorkspaceChatCommandHostResult) => void)
|
||||
| undefined;
|
||||
const staleLoad = new Promise<WorkspaceChatCommandHostResult>((resolve) => {
|
||||
resolveStaleLoad = resolve;
|
||||
});
|
||||
const staleShutdown = vi.fn(async () => {});
|
||||
const resources = createInteractiveWorkspaceResources({
|
||||
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
|
||||
userInstructionService: mutableService,
|
||||
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
|
||||
createPluginCommands: ({ cwd }) =>
|
||||
cwd === workspaceA
|
||||
? staleLoad
|
||||
: Promise.resolve(createPluginResult("plugin-b")),
|
||||
});
|
||||
|
||||
const loadingA = resources.loadPluginSlashCommands();
|
||||
const changing = resources.changeWorkspace(
|
||||
{ cwd: workspaceB, workspaceRoot: workspaceB },
|
||||
async () => {},
|
||||
);
|
||||
resolveStaleLoad?.(createPluginResult("plugin-a", staleShutdown));
|
||||
await Promise.all([loadingA, changing]);
|
||||
|
||||
expect(resources.getCommandSnapshot().pluginSlashCommands).toEqual([
|
||||
expect.objectContaining({ name: "plugin-b" }),
|
||||
]);
|
||||
expect(staleShutdown).toHaveBeenCalledOnce();
|
||||
|
||||
await resources.dispose();
|
||||
mutableService.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { BasicLogger, UserInstructionConfigService } from "@cline/core";
|
||||
import type { InteractiveSlashCommand } from "../../tui/interactive-welcome";
|
||||
import { listInteractiveSlashCommands } from "../../tui/interactive-welcome";
|
||||
import {
|
||||
type ChatCommandHost,
|
||||
chatCommandHost,
|
||||
} from "../../utils/chat-commands";
|
||||
import type { MutableUserInstructionConfigService } from "../../utils/mutable-user-instruction-service";
|
||||
import {
|
||||
createWorkspaceChatCommandHost,
|
||||
type WorkspaceChatCommandHostResult,
|
||||
} from "../../utils/plugin-chat-commands";
|
||||
|
||||
export interface InteractiveWorkspaceLocation {
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
export interface InteractiveWorkspaceCommandSnapshot {
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginSlashCommands: InteractiveSlashCommand[];
|
||||
}
|
||||
|
||||
interface WorkspacePluginCommands {
|
||||
host: ChatCommandHost;
|
||||
commands: InteractiveSlashCommand[];
|
||||
shutdown?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function toPluginCommands(
|
||||
result: WorkspaceChatCommandHostResult,
|
||||
): WorkspacePluginCommands {
|
||||
return {
|
||||
host: result.host,
|
||||
commands: result.pluginSlashCommands.map((command) => ({
|
||||
name: command.name,
|
||||
instructions: "",
|
||||
description: command.description ?? "Plugin command",
|
||||
})),
|
||||
shutdown: result.shutdown,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractiveWorkspaceResources(input: {
|
||||
initialLocation: InteractiveWorkspaceLocation;
|
||||
userInstructionService: MutableUserInstructionConfigService;
|
||||
createUserInstructionService: (
|
||||
location: InteractiveWorkspaceLocation,
|
||||
) => UserInstructionConfigService;
|
||||
logger?: BasicLogger;
|
||||
createPluginCommands?: (
|
||||
location: InteractiveWorkspaceLocation,
|
||||
) => Promise<WorkspaceChatCommandHostResult>;
|
||||
onCommandsChanged?: (snapshot: InteractiveWorkspaceCommandSnapshot) => void;
|
||||
}) {
|
||||
let location = input.initialLocation;
|
||||
let pluginCommands: WorkspacePluginCommands = {
|
||||
host: chatCommandHost,
|
||||
commands: [],
|
||||
};
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let pluginCommandsLoaded = false;
|
||||
let pluginLoadPromise: Promise<InteractiveSlashCommand[]> | undefined;
|
||||
let workspaceChangePromise: Promise<void> | undefined;
|
||||
const createPluginCommands = async (next: InteractiveWorkspaceLocation) =>
|
||||
toPluginCommands(
|
||||
await (input.createPluginCommands
|
||||
? input.createPluginCommands(next)
|
||||
: createWorkspaceChatCommandHost({
|
||||
cwd: next.cwd,
|
||||
workspaceRoot: next.workspaceRoot,
|
||||
logger: input.logger,
|
||||
})),
|
||||
);
|
||||
const snapshot = (): InteractiveWorkspaceCommandSnapshot => ({
|
||||
workflowSlashCommands: listInteractiveSlashCommands(
|
||||
input.userInstructionService,
|
||||
),
|
||||
pluginSlashCommands: pluginCommands.commands,
|
||||
});
|
||||
|
||||
const loadPluginSlashCommands = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
if (disposed) {
|
||||
return [];
|
||||
}
|
||||
if (pluginCommandsLoaded) {
|
||||
return pluginCommands.commands;
|
||||
}
|
||||
if (pluginLoadPromise) {
|
||||
return await pluginLoadPromise;
|
||||
}
|
||||
const loadGeneration = generation;
|
||||
const loadLocation = location;
|
||||
const load = (async () => {
|
||||
const loaded = await createPluginCommands(loadLocation);
|
||||
if (disposed || generation !== loadGeneration) {
|
||||
await loaded.shutdown?.().catch(() => {});
|
||||
return pluginCommands.commands;
|
||||
}
|
||||
const previous = pluginCommands;
|
||||
pluginCommands = loaded;
|
||||
pluginCommandsLoaded = true;
|
||||
await previous.shutdown?.().catch(() => {});
|
||||
return loaded.commands;
|
||||
})();
|
||||
pluginLoadPromise = load;
|
||||
try {
|
||||
return await load;
|
||||
} finally {
|
||||
if (pluginLoadPromise === load) {
|
||||
pluginLoadPromise = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyWorkspaceChange = async (
|
||||
next: InteractiveWorkspaceLocation,
|
||||
applySessionChange: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
if (disposed) {
|
||||
throw new Error("interactive workspace resources are disposed");
|
||||
}
|
||||
generation += 1;
|
||||
const nextService = input.createUserInstructionService(next);
|
||||
let nextPluginCommands: WorkspacePluginCommands | undefined;
|
||||
try {
|
||||
await nextService.start();
|
||||
input.userInstructionService.assertCompatible(nextService);
|
||||
nextPluginCommands = await createPluginCommands(next);
|
||||
await applySessionChange();
|
||||
} catch (error) {
|
||||
try {
|
||||
nextService.stop();
|
||||
} catch {}
|
||||
await nextPluginCommands?.shutdown?.().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previousService = input.userInstructionService.replace(nextService);
|
||||
const previousPluginCommands = pluginCommands;
|
||||
location = next;
|
||||
pluginCommands = nextPluginCommands;
|
||||
pluginCommandsLoaded = true;
|
||||
// The instruction delegate, plugin host, and TUI catalog become visible as
|
||||
// one workspace snapshot after the replacement agent session is live.
|
||||
try {
|
||||
input.onCommandsChanged?.(snapshot());
|
||||
} catch (error) {
|
||||
input.logger?.log("workspace command catalog notification failed", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
previousService.stop();
|
||||
} catch {}
|
||||
await previousPluginCommands.shutdown?.().catch(() => {});
|
||||
};
|
||||
|
||||
const changeWorkspace = (
|
||||
next: InteractiveWorkspaceLocation,
|
||||
applySessionChange: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
let change: Promise<void>;
|
||||
change = (async () => {
|
||||
await workspaceChangePromise?.catch(() => {});
|
||||
await applyWorkspaceChange(next, applySessionChange);
|
||||
})().finally(() => {
|
||||
if (workspaceChangePromise === change) {
|
||||
workspaceChangePromise = undefined;
|
||||
}
|
||||
});
|
||||
workspaceChangePromise = change;
|
||||
return change;
|
||||
};
|
||||
|
||||
const dispose = async (): Promise<void> => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
generation += 1;
|
||||
await workspaceChangePromise?.catch(() => {});
|
||||
await pluginLoadPromise?.catch(() => {});
|
||||
await pluginCommands.shutdown?.().catch(() => {});
|
||||
pluginCommands = { host: chatCommandHost, commands: [] };
|
||||
pluginCommandsLoaded = false;
|
||||
};
|
||||
|
||||
return {
|
||||
changeWorkspace,
|
||||
dispose,
|
||||
getChatCommandHost: () => pluginCommands.host,
|
||||
getCommandSnapshot: snapshot,
|
||||
arePluginCommandsLoaded: () => pluginCommandsLoaded,
|
||||
loadPluginSlashCommands,
|
||||
};
|
||||
}
|
||||
@@ -51,6 +51,21 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userImages).toEqual([]);
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
|
||||
it("resolves relative file mentions from the configured working directory", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cli-prompt-cwd-"));
|
||||
const filePath = join(dir, "notes.md");
|
||||
writeFileSync(filePath, "# Notes\n");
|
||||
|
||||
const result = await buildUserInputMessage(
|
||||
"summarize @./notes.md",
|
||||
undefined,
|
||||
{ cwd: dir },
|
||||
);
|
||||
|
||||
expect(result.prompt).toBe("summarize [file: notes.md]");
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
|
||||
@@ -74,11 +74,11 @@ function extractFileMentions(
|
||||
return matches;
|
||||
}
|
||||
|
||||
function resolveMentionPath(filePath: string): string {
|
||||
function resolveMentionPath(filePath: string, cwd: string): string {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return resolve(homedir(), filePath.slice(2));
|
||||
}
|
||||
return resolve(filePath);
|
||||
return resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,7 @@ export function shouldExpandSkillSlashCommands(mode?: string): boolean {
|
||||
export async function buildUserInputMessage(
|
||||
rawPrompt: string,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
options?: { mode?: string },
|
||||
options?: { mode?: string; cwd?: string },
|
||||
): Promise<{
|
||||
prompt: string;
|
||||
userImages: string[];
|
||||
@@ -154,7 +154,10 @@ export async function buildUserInputMessage(
|
||||
|
||||
for (const mention of fileMentions) {
|
||||
try {
|
||||
const resolvedPath = resolveMentionPath(mention.path);
|
||||
const resolvedPath = resolveMentionPath(
|
||||
mention.path,
|
||||
options?.cwd ?? process.cwd(),
|
||||
);
|
||||
const stats = statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${resolvedPath}`);
|
||||
|
||||
@@ -279,6 +279,7 @@ export async function runAgent(
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
mode: config.mode,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
createComputerUseToolFromEnv,
|
||||
getCurrentContextSize,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
@@ -23,7 +24,6 @@ import type {
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../tui/interactive-config";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
listInteractiveSlashCommands,
|
||||
resolveClineWelcomeLine,
|
||||
} from "../tui/interactive-welcome";
|
||||
@@ -36,12 +36,12 @@ import {
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import type { MutableUserInstructionConfigService } from "../utils/mutable-user-instruction-service";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
writeln,
|
||||
} from "../utils/output";
|
||||
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./active-runtime";
|
||||
import { createInteractiveApprovalController } from "./interactive/approvals";
|
||||
import { runInteractiveChatCommand } from "./interactive/chat-command-runner";
|
||||
import { createInteractiveComputerUser } from "./interactive/computer-user";
|
||||
import { createInteractiveConfigDataLoader } from "./interactive/config-data";
|
||||
import {
|
||||
formatInteractiveExitSummary,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
buildInteractiveExtraTools,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
@@ -67,6 +69,11 @@ import {
|
||||
} from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import {
|
||||
createInteractiveWorkspaceResources,
|
||||
type InteractiveWorkspaceCommandSnapshot,
|
||||
type InteractiveWorkspaceLocation,
|
||||
} from "./interactive/workspace-resources";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { getUIEventEmitter } from "./session-events";
|
||||
|
||||
@@ -185,51 +192,53 @@ export async function runInteractive(
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
explicitSystemPrompt?: string;
|
||||
mutableUserInstructionService?: MutableUserInstructionConfigService;
|
||||
createUserInstructionService?: (
|
||||
location: InteractiveWorkspaceLocation,
|
||||
) => UserInstructionConfigService;
|
||||
},
|
||||
): Promise<void> {
|
||||
assertInteractivePreflight(config);
|
||||
|
||||
const initialRepoStatus = await readRepoStatus(config.cwd);
|
||||
const workflowSlashCommands = listInteractiveSlashCommands(
|
||||
userInstructionService,
|
||||
);
|
||||
let interactiveChatCommandHost = chatCommandHost;
|
||||
let pluginChatCommandHostLoaded = false;
|
||||
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
|
||||
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
|
||||
let pluginChatCommandHostPromise:
|
||||
| Promise<InteractiveSlashCommand[]>
|
||||
const mutableUserInstructionService = options?.mutableUserInstructionService;
|
||||
const createUserInstructionService = options?.createUserInstructionService;
|
||||
const activeUserInstructionService =
|
||||
mutableUserInstructionService ?? userInstructionService;
|
||||
if (
|
||||
(mutableUserInstructionService === undefined) !==
|
||||
(createUserInstructionService === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
"interactive workspace resources require both the mutable instruction service and its factory",
|
||||
);
|
||||
}
|
||||
let workspaceCommandNotifier:
|
||||
| ((snapshot: InteractiveWorkspaceCommandSnapshot) => void)
|
||||
| undefined;
|
||||
const ensurePluginChatCommandHost = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
if (pluginChatCommandHostLoaded) {
|
||||
return pluginChatSlashCommands;
|
||||
}
|
||||
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
logger: config.logger,
|
||||
})
|
||||
.then(({ host, pluginSlashCommands, shutdown }) => {
|
||||
interactiveChatCommandHost = host;
|
||||
pluginChatCommandHostShutdown = shutdown;
|
||||
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
instructions: "",
|
||||
description: cmd.description ?? "Plugin command",
|
||||
}));
|
||||
return pluginChatSlashCommands;
|
||||
})
|
||||
.finally(() => {
|
||||
pluginChatCommandHostLoaded = true;
|
||||
pluginChatCommandHostPromise = undefined;
|
||||
});
|
||||
return await pluginChatCommandHostPromise;
|
||||
const workspaceResources =
|
||||
mutableUserInstructionService && createUserInstructionService
|
||||
? createInteractiveWorkspaceResources({
|
||||
initialLocation: {
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
},
|
||||
userInstructionService: mutableUserInstructionService,
|
||||
createUserInstructionService,
|
||||
logger: config.logger,
|
||||
onCommandsChanged: (snapshot) => workspaceCommandNotifier?.(snapshot),
|
||||
})
|
||||
: undefined;
|
||||
const initialCommandSnapshot = workspaceResources?.getCommandSnapshot() ?? {
|
||||
workflowSlashCommands: listInteractiveSlashCommands(
|
||||
activeUserInstructionService,
|
||||
),
|
||||
pluginSlashCommands: [],
|
||||
};
|
||||
const loadAdditionalSlashCommands = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => await ensurePluginChatCommandHost();
|
||||
const loadAdditionalSlashCommands = workspaceResources
|
||||
? workspaceResources.loadPluginSlashCommands
|
||||
: undefined;
|
||||
const shouldTryPluginChatCommands = (prompt: string): boolean => {
|
||||
return prompt.trimStart().startsWith("/");
|
||||
};
|
||||
@@ -258,7 +267,47 @@ export async function runInteractive(
|
||||
tuiModeChanged,
|
||||
});
|
||||
|
||||
config.extraTools = config.mode === "plan" ? [switchToActModeTool] : [];
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
|
||||
// Computer-use support, enabled when CLINE_COMPUTER_USE_PORT points at a
|
||||
// running backend. Preferred shape: the asynchronous computer user (a
|
||||
// dedicated Anthropic helper session behind computer_user_* tools). When
|
||||
// the Anthropic provider is not configured, fall back to giving the
|
||||
// driver the raw `computer` tool directly.
|
||||
//
|
||||
// notifyDriver closes over sessionRuntime (declared below) but only runs
|
||||
// after a driver turn has started, long after initialization. It resolves
|
||||
// the driver session id at call time, so session rebuilds are safe.
|
||||
const computerUser = await createInteractiveComputerUser({
|
||||
config,
|
||||
providerSettingsManager,
|
||||
notifyDriver: (prompt, delivery) => {
|
||||
void sessionRuntime
|
||||
.sendCurrentTurn({ prompt, delivery })
|
||||
.catch((error) => {
|
||||
logCliError(
|
||||
config.logger,
|
||||
"Computer-user driver notification failed",
|
||||
{
|
||||
error,
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
const computerUseTool = computerUser
|
||||
? undefined
|
||||
: await createComputerUseToolFromEnv();
|
||||
const persistentExtraTools = [
|
||||
...(computerUser ? computerUser.driverTools : []),
|
||||
...(computerUseTool ? [computerUseTool] : []),
|
||||
];
|
||||
|
||||
config.extraTools = buildInteractiveExtraTools({
|
||||
mode: config.mode === "plan" ? "plan" : "act",
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
});
|
||||
|
||||
const uiEvents = getUIEventEmitter();
|
||||
const chatCommandState: ChatCommandState = {
|
||||
@@ -271,13 +320,13 @@ export async function runInteractive(
|
||||
autoApproveAllRef,
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager,
|
||||
userInstructionService,
|
||||
userInstructionService: activeUserInstructionService,
|
||||
explicitSystemPrompt: options?.explicitSystemPrompt,
|
||||
resumeSessionId,
|
||||
chatCommandState,
|
||||
requestToolApproval,
|
||||
@@ -285,6 +334,11 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
persistentExtraTools,
|
||||
// Record the driver's transcript to the computer-use backend's
|
||||
// journal so the observatory can show it beside the computer user's
|
||||
// transcript and screenshots.
|
||||
extraAgentHooks: computerUser?.driverRecordingHooks,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
},
|
||||
@@ -300,10 +354,24 @@ export async function runInteractive(
|
||||
});
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
userInstructionService: activeUserInstructionService,
|
||||
loadCoreSettings: sessionRuntime.listCoreSettings,
|
||||
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
|
||||
});
|
||||
const changeInteractiveWorkingDirectory = async (
|
||||
next: ChatCommandState,
|
||||
): Promise<void> => {
|
||||
const applySessionChange = () =>
|
||||
sessionRuntime.changeWorkingDirectory(next);
|
||||
if (!workspaceResources) {
|
||||
await applySessionChange();
|
||||
return;
|
||||
}
|
||||
await workspaceResources.changeWorkspace(
|
||||
{ cwd: next.cwd, workspaceRoot: next.workspaceRoot },
|
||||
applySessionChange,
|
||||
);
|
||||
};
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
@@ -385,11 +453,8 @@ export async function runInteractive(
|
||||
try {
|
||||
exitSummary = await sessionRuntime.cleanup();
|
||||
} finally {
|
||||
await pluginChatCommandHostPromise?.catch(() => []);
|
||||
await pluginChatCommandHostShutdown?.().catch(() => {
|
||||
// Best effort cleanup for plugin command discovery sandbox.
|
||||
});
|
||||
pluginChatCommandHostShutdown = undefined;
|
||||
await computerUser?.dispose().catch(() => {});
|
||||
await workspaceResources?.dispose();
|
||||
setActiveRuntimeAbort(undefined);
|
||||
setActiveRuntimeCleanup(undefined);
|
||||
}
|
||||
@@ -523,7 +588,7 @@ export async function runInteractive(
|
||||
onInitialNoticeShown: options?.onInitialNoticeShown,
|
||||
loadDeferredInitialMessages,
|
||||
initialRepoStatus,
|
||||
workflowSlashCommands,
|
||||
workflowSlashCommands: initialCommandSnapshot.workflowSlashCommands,
|
||||
loadAdditionalSlashCommands,
|
||||
loadWelcomeLine: async () =>
|
||||
await resolveClineWelcomeLine({
|
||||
@@ -582,12 +647,14 @@ export async function runInteractive(
|
||||
let chatCommandResult = await runInteractiveChatCommand({
|
||||
prompt: input,
|
||||
enabled: enableChatCommands,
|
||||
delivery,
|
||||
config,
|
||||
host: interactiveChatCommandHost,
|
||||
host: workspaceResources?.getChatCommandHost() ?? chatCommandHost,
|
||||
chatCommandState,
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
changeWorkingDirectory: changeInteractiveWorkingDirectory,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
@@ -596,18 +663,21 @@ export async function runInteractive(
|
||||
}
|
||||
if (
|
||||
shouldTryPluginChatCommands(input) &&
|
||||
!pluginChatCommandHostLoaded
|
||||
workspaceResources &&
|
||||
!workspaceResources.arePluginCommandsLoaded()
|
||||
) {
|
||||
await ensurePluginChatCommandHost();
|
||||
await workspaceResources.loadPluginSlashCommands();
|
||||
chatCommandResult = await runInteractiveChatCommand({
|
||||
prompt: input,
|
||||
enabled: enableChatCommands,
|
||||
delivery,
|
||||
config,
|
||||
host: interactiveChatCommandHost,
|
||||
host: workspaceResources.getChatCommandHost(),
|
||||
chatCommandState,
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
changeWorkingDirectory: changeInteractiveWorkingDirectory,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
@@ -623,8 +693,9 @@ export async function runInteractive(
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionService, {
|
||||
} = await buildUserInputMessage(input, activeUserInstructionService, {
|
||||
mode,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
const mergedUserImages = [
|
||||
...(attachments?.userImages ?? []),
|
||||
@@ -860,6 +931,12 @@ export async function runInteractive(
|
||||
setModeChangeNotifier: (fn) => {
|
||||
tuiModeChanged.current = fn;
|
||||
},
|
||||
setWorkspaceCommandNotifier: (fn) => {
|
||||
workspaceCommandNotifier = fn ?? undefined;
|
||||
if (fn && workspaceResources) {
|
||||
fn(workspaceResources.getCommandSnapshot());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
|
||||
|
||||
@@ -82,6 +82,7 @@ export async function runZen(
|
||||
// Zen runs in yolo mode, whose preset has no skills tool — skill
|
||||
// commands must keep expanding textually.
|
||||
mode: "yolo",
|
||||
cwd: config.cwd,
|
||||
});
|
||||
|
||||
const startRequest: ChatStartSessionRequest = {
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
@@ -160,6 +158,38 @@ 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;
|
||||
@@ -183,12 +213,16 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = resolveClineAccountTelemetryIdentity(user);
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineAccountTelemetryIdentity(
|
||||
new ProviderSettingsManager(),
|
||||
accountContext,
|
||||
);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
||||
@@ -316,4 +316,26 @@ describe("slash command registry", () => {
|
||||
getVisibleSystemSlashCommands(registry).map((command) => command.name),
|
||||
).toContain("account");
|
||||
});
|
||||
|
||||
it("exposes cd as a runtime command", () => {
|
||||
const registry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
{
|
||||
name: "cd",
|
||||
instructions: "/cd <directory>",
|
||||
description: "Change the working directory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveSlashCommand(registry, "cd")).toMatchObject({
|
||||
source: "runtime",
|
||||
execution: "runtime",
|
||||
visible: true,
|
||||
selectable: true,
|
||||
});
|
||||
expect(
|
||||
getVisibleSystemSlashCommands(registry).map((command) => command.name),
|
||||
).toContain("cd");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
|
||||
const SYSTEM_COMMAND_ORDER = [
|
||||
"settings",
|
||||
"cd",
|
||||
"model",
|
||||
"theme",
|
||||
"account",
|
||||
@@ -130,7 +131,7 @@ const SYSTEM_COMMAND_ORDER = [
|
||||
"history",
|
||||
"help",
|
||||
"quit",
|
||||
] satisfies ReadonlyArray<LocalSlashCommandName | "team">;
|
||||
] satisfies ReadonlyArray<LocalSlashCommandName | "cd" | "team">;
|
||||
|
||||
const SYSTEM_COMMAND_PRIORITY = new Map<string, number>(
|
||||
SYSTEM_COMMAND_ORDER.map((name, index) => [name, index]),
|
||||
|
||||
@@ -133,6 +133,12 @@ const HELP_ROWS: HelpRow[] = [
|
||||
key: "/theme",
|
||||
desc: "Change color theme",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-cd",
|
||||
key: "/cd <directory>",
|
||||
desc: "Change the working directory",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-mcp",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -16,10 +15,11 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
checkLocalCliInstalled,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { useDialogPalette } from "../../hooks/use-theme";
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
import { getProviderSection } from "../../utils/provider-sections";
|
||||
import { canContinueLocalCliSetup } from "../../views/onboarding/model";
|
||||
import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
@@ -83,7 +82,7 @@ export function ProviderPickerContent(
|
||||
// just a model id and base URL) still render as configured.
|
||||
isConfigured: p.enabled === true,
|
||||
isOAuth: isOAuthProvider(p.id),
|
||||
isLocalAuth: isLocalAuthProvider(p.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(p.id),
|
||||
capabilities: p.capabilities,
|
||||
}));
|
||||
setProviders(providerItems);
|
||||
@@ -655,25 +654,29 @@ export function ProviderConfigInputContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalCliStatusContent(
|
||||
export function CodexCliStatusContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
cli?: ProviderLocalCli;
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, cli, providerName } = props;
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const palette = useDialogPalette();
|
||||
const [status, setStatus] = useState<LocalCliStatus | undefined>();
|
||||
const [status, setStatus] = useState<CodexCliStatus | undefined>();
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!cli) return;
|
||||
setStatus(undefined);
|
||||
setChecking(true);
|
||||
checkLocalCliInstalled(cli)
|
||||
checkCodexCliInstalled()
|
||||
.then(setStatus)
|
||||
.catch((error: unknown) => {
|
||||
setStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => setChecking(false));
|
||||
}, [cli]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -688,7 +691,7 @@ export function LocalCliStatusContent(
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" && canContinueLocalCliSetup(cli, status)) {
|
||||
if (key.name === "return" && status?.installed) {
|
||||
resolve(true);
|
||||
}
|
||||
}, dialogId);
|
||||
@@ -699,37 +702,31 @@ export function LocalCliStatusContent(
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
{checking && <text fg="gray">Checking for {providerName}...</text>}
|
||||
{checking && <text fg="gray">Checking for Codex CLI...</text>}
|
||||
|
||||
{status?.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={palette.success}>
|
||||
{"\u25cf"} {providerName} installed
|
||||
</text>
|
||||
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg="gray">{status.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{status && !status.installed && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">{providerName} was not found</text>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="gray">{status.reason}</text>
|
||||
{cli?.docsUrl && (
|
||||
<box flexDirection="column">
|
||||
<text fg="gray">Install {providerName} from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{cli.docsUrl}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={palette.act} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{cli
|
||||
{status?.installed
|
||||
? "Enter to continue, R to recheck, Esc to go back"
|
||||
: "Enter to continue, Esc to go back"}
|
||||
: "R to recheck, Esc to go back"}
|
||||
</em>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -23,9 +23,6 @@ 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];
|
||||
|
||||
@@ -35,7 +32,7 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
zIndex={100}
|
||||
top={1}
|
||||
right={right}
|
||||
width={boxWidth}
|
||||
maxWidth={maxWidth}
|
||||
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 { getLocalCliInfo } from "../../utils/local-cli";
|
||||
import { isOpenAICodexCliProvider } from "../../utils/codex-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,7 +43,6 @@ import {
|
||||
type ThinkingLevel,
|
||||
ThinkingLevelContent,
|
||||
} from "../components/model-selector/model-selector";
|
||||
import { resolveProviderSetupRoute } from "../views/onboarding/model";
|
||||
|
||||
export interface OpenModelSelectorOptions {
|
||||
onCancel?: () => Promise<void> | void;
|
||||
@@ -179,9 +178,6 @@ 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.
|
||||
@@ -250,16 +246,12 @@ async function runProviderChange(
|
||||
loginResult === "use_api_key"
|
||||
? await openManualApiKeyDialog()
|
||||
: loginResult;
|
||||
} else if (needsLocalCliSetup) {
|
||||
} else if (isOpenAICodexCliProvider(newProviderId)) {
|
||||
saved = await dialog.choice<boolean>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<LocalCliStatusContent
|
||||
{...ctx}
|
||||
cli={localCliProvider}
|
||||
providerName={displayName}
|
||||
/>
|
||||
<CodexCliStatusContent {...ctx} providerName={displayName} />
|
||||
),
|
||||
});
|
||||
if (saved) {
|
||||
|
||||
@@ -60,5 +60,11 @@ export function useSlashCommands(input: {
|
||||
[registry],
|
||||
);
|
||||
|
||||
return { registry, systemCommands, skillCommands, invokableSkillCommands };
|
||||
return {
|
||||
registry,
|
||||
systemCommands,
|
||||
skillCommands,
|
||||
invokableSkillCommands,
|
||||
setAdditionalSlashCommands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ export function listInteractiveSlashCommands(
|
||||
instructions: "",
|
||||
description: "Modify agent configuration",
|
||||
},
|
||||
{
|
||||
name: "cd",
|
||||
instructions: "/cd <directory>",
|
||||
description: "Change the working directory",
|
||||
},
|
||||
{
|
||||
name: "mcp",
|
||||
instructions: "",
|
||||
|
||||
@@ -140,12 +140,21 @@ function App(props: TuiProps) {
|
||||
systemCommands,
|
||||
skillCommands,
|
||||
invokableSkillCommands,
|
||||
setAdditionalSlashCommands,
|
||||
} = useSlashCommands({
|
||||
workflowSlashCommands,
|
||||
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
|
||||
canFork: canForkSession,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
props.setWorkspaceCommandNotifier((snapshot) => {
|
||||
setWorkflowSlashCommands(snapshot.workflowSlashCommands);
|
||||
setAdditionalSlashCommands(snapshot.pluginSlashCommands);
|
||||
});
|
||||
return () => props.setWorkspaceCommandNotifier(null);
|
||||
}, [props.setWorkspaceCommandNotifier, setAdditionalSlashCommands]);
|
||||
|
||||
const autocomplete = useAutocomplete({
|
||||
workspaceRoot,
|
||||
systemCommands,
|
||||
|
||||
@@ -246,6 +246,14 @@ export interface TuiProps {
|
||||
handler: ((question: string, options: string[]) => Promise<string>) | null,
|
||||
) => void;
|
||||
setModeChangeNotifier: (handler: ((mode: AgentMode) => void) | null) => void;
|
||||
setWorkspaceCommandNotifier: (
|
||||
handler:
|
||||
| ((snapshot: {
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginSlashCommands: InteractiveSlashCommand[];
|
||||
}) => void)
|
||||
| null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export type InlineStream = "text" | "reasoning" | undefined;
|
||||
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
getIndividualPlanFeatures,
|
||||
} from "../../../utils/cline-pass-errors";
|
||||
import {
|
||||
checkLocalCliInstalled,
|
||||
getLocalCliInfo,
|
||||
type LocalCliStatus,
|
||||
type ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import open from "../../../utils/open";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
@@ -60,7 +59,6 @@ import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
DEFAULT_THINKING_LEVEL_INDEX,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
@@ -68,7 +66,6 @@ import {
|
||||
type OnboardingStep,
|
||||
type ProviderEntry,
|
||||
type ReasoningEffort,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
type ThinkingLevel,
|
||||
toModelEntriesFromKnownModels,
|
||||
@@ -106,10 +103,6 @@ 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"]>(
|
||||
{},
|
||||
);
|
||||
@@ -117,11 +110,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
|
||||
const [byoFocusedField, setByoFocusedField] =
|
||||
useState<ProviderConfigFieldKey>("apiKey");
|
||||
const [localCliStatus, setLocalCliStatus] = useState<
|
||||
LocalCliStatus | undefined
|
||||
const [codexCliStatus, setCodexCliStatus] = useState<
|
||||
CodexCliStatus | undefined
|
||||
>();
|
||||
const [localCliChecking, setLocalCliChecking] = useState(false);
|
||||
const localCliProbeRef = useRef(0);
|
||||
const [codexCliChecking, setCodexCliChecking] = useState(false);
|
||||
const authAbortRef = useRef(false);
|
||||
|
||||
// Device code flow
|
||||
@@ -494,23 +486,18 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
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);
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
checkCodexCliInstalled()
|
||||
.then(setCodexCliStatus)
|
||||
.catch((error: unknown) => {
|
||||
setCodexCliStatus({
|
||||
installed: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrentProbe()) setLocalCliChecking(false);
|
||||
});
|
||||
.finally(() => setCodexCliChecking(false));
|
||||
}, []);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
@@ -523,14 +510,12 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resolveProviderSetupRoute(provider.id) === "local_cli") {
|
||||
if (provider.isLocalAuth || isOpenAICodexCliProvider(provider.id)) {
|
||||
setActiveProviderId(provider.id);
|
||||
setActiveProviderName(provider.name);
|
||||
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);
|
||||
setCodexCliStatus(undefined);
|
||||
setStep("codex_cli_setup");
|
||||
refreshCodexCliStatus();
|
||||
return;
|
||||
}
|
||||
const config = getProviderConfigFields(provider.id);
|
||||
@@ -590,17 +575,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setByoFocusedField(firstField ?? "apiKey");
|
||||
setStep("byo_apikey");
|
||||
},
|
||||
[providers, startOAuthFlow, refreshLocalCliStatus, providerSettingsManager],
|
||||
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
|
||||
);
|
||||
|
||||
const recheckLocalCli = useCallback(() => {
|
||||
if (localCli) {
|
||||
refreshLocalCliStatus(localCli);
|
||||
}
|
||||
}, [localCli, refreshLocalCliStatus]);
|
||||
|
||||
const saveLocalCliConfig = useCallback(() => {
|
||||
if (!canContinueLocalCliSetup(localCli, localCliStatus)) {
|
||||
const saveCodexCliConfig = useCallback(() => {
|
||||
if (!codexCliStatus?.installed) {
|
||||
return;
|
||||
}
|
||||
saveLocalProviderSettings(providerSettingsManager, {
|
||||
@@ -609,8 +588,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
transitionToModelPicker(activeProviderId);
|
||||
}, [
|
||||
activeProviderId,
|
||||
localCli,
|
||||
localCliStatus,
|
||||
codexCliStatus,
|
||||
providerSettingsManager,
|
||||
transitionToModelPicker,
|
||||
]);
|
||||
@@ -820,13 +798,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
deviceAbortRef.current = true;
|
||||
},
|
||||
resetAuth,
|
||||
refreshLocalCliStatus: recheckLocalCli,
|
||||
refreshCodexCliStatus,
|
||||
startOAuthFlow,
|
||||
startDeviceCodeFlow,
|
||||
selectProvider,
|
||||
loadModelsForProvider,
|
||||
saveClineModelSelection,
|
||||
saveLocalCliConfig,
|
||||
saveCodexCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
saveThinkingLevel,
|
||||
@@ -842,9 +820,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
byoFields,
|
||||
byoFocusedField,
|
||||
byoValues,
|
||||
localCli,
|
||||
localCliChecking,
|
||||
localCliStatus,
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
@@ -883,7 +860,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providersLoading,
|
||||
recommendedLoading: recommended.loading,
|
||||
saveByoConfig,
|
||||
saveLocalCliConfig,
|
||||
saveCodexCliConfig,
|
||||
saveCustomModelId,
|
||||
selectedModelName,
|
||||
step,
|
||||
|
||||
@@ -51,13 +51,13 @@ export function useOnboardingKeyboard(input: {
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
refreshLocalCliStatus: () => void;
|
||||
refreshCodexCliStatus: () => void;
|
||||
startOAuthFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
saveLocalCliConfig: () => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
saveThinkingLevel: (level: ThinkingLevel) => void;
|
||||
@@ -98,7 +98,7 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "local_cli_setup") {
|
||||
if (input.step === "codex_cli_setup") {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
@@ -227,13 +227,13 @@ export function useOnboardingKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "local_cli_setup") {
|
||||
if (input.step === "codex_cli_setup") {
|
||||
if (key.name === "r") {
|
||||
input.refreshLocalCliStatus();
|
||||
input.refreshCodexCliStatus();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
input.saveLocalCliConfig();
|
||||
input.saveCodexCliConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getLocalCliInfo } from "../../../utils/local-cli";
|
||||
|
||||
vi.mock("../../../utils/local-cli", () => ({
|
||||
getLocalCliInfo: () => undefined,
|
||||
}));
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canContinueLocalCliSetup,
|
||||
getMainMenuOptions,
|
||||
getOAuthProviderLabel,
|
||||
resolveProviderSetupRoute,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
@@ -85,20 +77,6 @@ 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({
|
||||
@@ -188,31 +166,3 @@ 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,14 +4,8 @@ import type {
|
||||
ModelOperation,
|
||||
} from "@cline/shared";
|
||||
import { isChatProviderModel } from "../../../utils/chat-models";
|
||||
import type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
} from "../../../utils/provider-auth";
|
||||
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
|
||||
export type OnboardingStep =
|
||||
| "menu"
|
||||
@@ -19,7 +13,7 @@ export type OnboardingStep =
|
||||
| "device_code"
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "local_cli_setup"
|
||||
| "codex_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
@@ -91,35 +85,6 @@ 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[] {
|
||||
@@ -209,7 +174,7 @@ export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isOAuth: isOAuthProvider(provider.id),
|
||||
isLocalAuth: isLocalAuthProvider(provider.id),
|
||||
isLocalAuth: isOpenAICodexCliProvider(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 type {
|
||||
LocalCliStatus,
|
||||
ProviderLocalCli,
|
||||
} from "../../../utils/local-cli";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
} from "../../../utils/codex-cli";
|
||||
import {
|
||||
ClineModelPicker,
|
||||
type ClineModelPickerEntry,
|
||||
@@ -25,7 +25,6 @@ import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
canContinueLocalCliSetup,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
@@ -363,20 +362,18 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingLocalCliScreen(props: {
|
||||
export function OnboardingCodexCliScreen(props: {
|
||||
activeProviderName: string;
|
||||
checking: boolean;
|
||||
cli?: ProviderLocalCli;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
mouse: MouseTrackerState;
|
||||
status?: LocalCliStatus;
|
||||
status?: CodexCliStatus;
|
||||
}) {
|
||||
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}
|
||||
@@ -389,37 +386,31 @@ export function OnboardingLocalCliScreen(props: {
|
||||
{props.checking && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">Checking for {props.activeProviderName}...</text>
|
||||
<text fg="gray">Checking for Codex CLI...</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{installedStatus && (
|
||||
<box flexDirection="column" gap={1} alignItems="center">
|
||||
<text fg={colors.success}>
|
||||
{"\u25cf"} {props.activeProviderName} installed
|
||||
</text>
|
||||
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
|
||||
<text fg="gray">{installedStatus.version}</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
{props.cli && props.status && !props.status.installed && (
|
||||
{props.status && !props.status.installed && (
|
||||
<box flexDirection="column" gap={1} width={props.contentWidth}>
|
||||
<text fg="yellow">{props.activeProviderName} was not found</text>
|
||||
<text fg="yellow">Codex CLI was not found</text>
|
||||
<text fg="gray">{props.status.reason}</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>
|
||||
)}
|
||||
<text fg="gray">Install Codex CLI from:</text>
|
||||
<text fg={colors.accent} selectable>
|
||||
{CODEX_CLI_INSTALL_URL}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
<text fg="gray">
|
||||
<em>
|
||||
{canContinue
|
||||
{installedStatus
|
||||
? "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,16 +83,15 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "local_cli_setup" && state.localCli) {
|
||||
if (state.step === "codex_cli_setup") {
|
||||
return (
|
||||
<OnboardingLocalCliScreen
|
||||
<OnboardingCodexCliScreen
|
||||
activeProviderName={state.activeProviderName}
|
||||
checking={state.localCliChecking}
|
||||
cli={state.localCli}
|
||||
checking={state.codexCliChecking}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
mouse={mouse}
|
||||
status={state.localCliStatus}
|
||||
status={state.codexCliStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createChatCommandHost,
|
||||
@@ -199,6 +202,65 @@ describe("chat commands", () => {
|
||||
expect(reply).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("changes directories with both /cd and the existing /cwd spelling", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-"));
|
||||
const target = join(root, "project with spaces");
|
||||
mkdirSync(target);
|
||||
|
||||
for (const command of [
|
||||
`/cd "project with spaces"`,
|
||||
`/cwd "project with spaces"`,
|
||||
]) {
|
||||
const state = {
|
||||
enableTools: true,
|
||||
autoApproveTools: false,
|
||||
cwd: root,
|
||||
workspaceRoot: root,
|
||||
};
|
||||
const setState = vi.fn(async (next) => Object.assign(state, next));
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
expect(
|
||||
await maybeHandleChatCommand(command, {
|
||||
enabled: true,
|
||||
getState: () => state,
|
||||
setState,
|
||||
reply,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(state.cwd).toBe(target);
|
||||
expect(setState).toHaveBeenCalledOnce();
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`cwd=${target}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the working directory unchanged when /cd is not a directory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-invalid-"));
|
||||
writeFileSync(join(root, "file.txt"), "not a directory");
|
||||
const setState = vi.fn(async () => undefined);
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
expect(
|
||||
await maybeHandleChatCommand("/cd file.txt", {
|
||||
enabled: true,
|
||||
getState: () => ({
|
||||
enableTools: true,
|
||||
autoApproveTools: false,
|
||||
cwd: root,
|
||||
workspaceRoot: root,
|
||||
}),
|
||||
setState,
|
||||
reply,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(setState).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
`invalid directory: ${join(root, "file.txt")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows usage for /team with no arguments", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "./helpers";
|
||||
|
||||
@@ -216,6 +217,22 @@ function tokenizeArgs(input: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resolveChatCommandDirectory(cwd: string, args: string[]): string {
|
||||
const rawPath = args.join(" ").trim();
|
||||
const unquotedPath =
|
||||
(rawPath.startsWith('"') && rawPath.endsWith('"')) ||
|
||||
(rawPath.startsWith("'") && rawPath.endsWith("'"))
|
||||
? rawPath.slice(1, -1)
|
||||
: rawPath;
|
||||
if (unquotedPath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (unquotedPath.startsWith("~/")) {
|
||||
return resolve(homedir(), unquotedPath.slice(2));
|
||||
}
|
||||
return resolve(cwd, unquotedPath);
|
||||
}
|
||||
|
||||
function parseFlagValues(tokens: string[]): {
|
||||
positionals: string[];
|
||||
flags: Record<string, string>;
|
||||
@@ -282,7 +299,7 @@ function formatHelp(state: ChatCommandState): string {
|
||||
"/whereami - show thread, cwd, tools, and yolo state",
|
||||
"/tools [on|off|toggle] - allow repo/file/shell tools",
|
||||
"/yolo [on|off|toggle] - auto-approve tool use",
|
||||
"/cwd <path> - change working directory",
|
||||
"/cd <path> (or /cwd <path>) - change working directory",
|
||||
"/schedule create/list/trigger/delete - manage scheduled workflows",
|
||||
"/abort - stop the current task",
|
||||
"/mute [target] - ignore this thread or target until /unmute",
|
||||
@@ -398,7 +415,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
},
|
||||
})
|
||||
.register("command", {
|
||||
names: ["/cwd"],
|
||||
names: ["/cd", "/cwd"],
|
||||
run: async ({ args, state }, context) => {
|
||||
const rawPath = args.join(" ").trim();
|
||||
if (!rawPath) {
|
||||
@@ -407,7 +424,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nextCwd = resolve(state.cwd, rawPath);
|
||||
const nextCwd = resolveChatCommandDirectory(state.cwd, args);
|
||||
const fileStat = await stat(nextCwd).catch(() => undefined);
|
||||
if (!fileStat?.isDirectory()) {
|
||||
await context.reply(`invalid directory: ${nextCwd}`);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
|
||||
export interface MutableUserInstructionConfigService
|
||||
extends UserInstructionConfigService {
|
||||
assertCompatible(next: UserInstructionConfigService): void;
|
||||
replace(next: UserInstructionConfigService): UserInstructionConfigService;
|
||||
}
|
||||
|
||||
export function createMutableUserInstructionConfigService(
|
||||
initial: UserInstructionConfigService,
|
||||
): MutableUserInstructionConfigService {
|
||||
let current = initial;
|
||||
const hasSkillsExecutor = typeof initial.createSkillsExecutor === "function";
|
||||
const assertCompatible = (next: UserInstructionConfigService): void => {
|
||||
if (
|
||||
(typeof next.createSkillsExecutor === "function") !==
|
||||
hasSkillsExecutor
|
||||
) {
|
||||
throw new Error(
|
||||
"Replacement instruction service has incompatible skills capability",
|
||||
);
|
||||
}
|
||||
};
|
||||
const service: UserInstructionConfigService = {
|
||||
start: () => current.start(),
|
||||
stop: () => current.stop(),
|
||||
refreshType: (type) => current.refreshType(type),
|
||||
listRecords: (type) => current.listRecords(type),
|
||||
listRuntimeCommands: () => current.listRuntimeCommands(),
|
||||
resolveRuntimeSlashCommand: (input) =>
|
||||
current.resolveRuntimeSlashCommand(input),
|
||||
hasConfiguredSkills: (allowedSkillNames) =>
|
||||
current.hasConfiguredSkills(allowedSkillNames),
|
||||
createExtension: (options) => current.createExtension(options),
|
||||
};
|
||||
if (hasSkillsExecutor) {
|
||||
service.createSkillsExecutor = (allowedSkillNames) => {
|
||||
if (!current.createSkillsExecutor) {
|
||||
throw new Error(
|
||||
"Replacement instruction service has no skills executor",
|
||||
);
|
||||
}
|
||||
return current.createSkillsExecutor(allowedSkillNames);
|
||||
};
|
||||
}
|
||||
return {
|
||||
...service,
|
||||
assertCompatible,
|
||||
replace: (next) => {
|
||||
assertCompatible(next);
|
||||
const previous = current;
|
||||
current = next;
|
||||
return previous;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isLocalAuthProvider,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
@@ -22,7 +21,7 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
export { isLocalAuthProvider, isOAuthProvider };
|
||||
export { isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
|
||||
@@ -109,9 +109,7 @@ export async function handleDesktopCommand(
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
return await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
|
||||
@@ -85,9 +85,7 @@ export async function loadModels(
|
||||
if (!provider) return;
|
||||
const payload = await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter((model) =>
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.25
|
||||
|
||||
- ChatGPT Subscription (Codex) now lists only the models your plan can actually use. Two separate paths filled the picker from the shared OpenAI catalog, so GPT-4o, GPT-4.1, and `chatgpt-image-latest` showed up alongside the Codex models, and the runtime lost the Codex context caps. The model rules also match what the backend now accepts: `gpt-5.4` and `gpt-5.4-mini` were retired for ChatGPT accounts on 2026-08-31 and are gone, the default moves to `gpt-5.6-terra`, and every Codex model is capped at the real 400K/272K/128K backend budget instead of inheriting the API's 1.05M limits
|
||||
- Windows updates no longer fail with "Error opening file for writing". The compiled sidecar re-executes itself as the detached Cline Hub daemon, which outlives the app by design, and Tauri's NSIS installer only kills the main binary — so the daemon still held `code-sidecar.exe` and the install stopped until you killed the process by hand. The installer now stops it first, matched on the full path so updating one channel does not take down a side-by-side Cline Beta's sessions
|
||||
- Your prompt is no longer lost when a send fails before the turn starts — switching to Codex and having the OAuth refresh throw, for instance. The runtime never took the prompt, so post-send hydration wiped the optimistic bubble and you had to retype it. The text and attachments now come back to the composer, merged with anything you attached while the send was pending, and left alone if you have already started typing something else
|
||||
- Providers that authenticate through a local CLI — Claude Code, Codex CLI — can now start sessions without an API key. They showed as Configured in Settings via their local-auth capability, but session start still refused them with "Missing API key"
|
||||
- OpenCode is now treated as a local CLI provider rather than an OAuth one, so it shows the local CLI notice instead of a browser sign-in button that could not do anything. It authenticates from the credentials the opencode CLI itself stores
|
||||
- Session import from Claude Code, Codex, and opencode has its own page in Settings instead of a row buried in General
|
||||
- The composer's provider picker now marks which providers you have already configured
|
||||
- The model picker distinguishes models that share a name, and Cline Pass subscription models are listed separately from the free fallback tier
|
||||
- Published DMGs use the intended window layout and background again. Tauri skips the Finder AppleScript that applies them whenever `CI` is set, which GitHub Actions always sets, so every DMG since the artwork landed shipped with a stock Finder window even though the artwork was generated and validated
|
||||
- Cline's recommended, free, and subscribed model lists now ship with the app, so they are correct at first launch instead of waiting on a live catalog fetch
|
||||
- Refreshed the model catalog. Adds NaN (nan.builders) and changes the resolved default model for 36 providers — including Bedrock, Vertex, OpenRouter, Kilo, GitHub Copilot, Gemini, Cerebras, Fireworks, Requesty, and Vercel AI Gateway. Several move off Claude Fable 5.1 to GPT-6 Astra, Vertex goes to Gemini 3.8 Flash, and OpenRouter/Kilo to Inception Mercury 2.5. If you use one of those without pinning a model, expect a different default
|
||||
|
||||
## 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.25",
|
||||
"version": "0.0.22",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -82,15 +81,6 @@ 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,8 +13,6 @@ import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
createDesktopMistakeLimitPrompt,
|
||||
createDesktopMistakeRecovery,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
@@ -24,11 +22,7 @@ import {
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import {
|
||||
handleCoreSessionEvent,
|
||||
requestSidecarAskQuestion,
|
||||
resolveSidecarAskQuestion,
|
||||
} from "./context";
|
||||
import { handleCoreSessionEvent } from "./context";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("resolveDesktopSessionMode", () => {
|
||||
@@ -201,49 +195,25 @@ 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>;
|
||||
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 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 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, {
|
||||
@@ -575,7 +545,7 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a full-history fork on the current workspace and cancels source questions", async () => {
|
||||
it("keeps a full-history fork on the current workspace without restoring", async () => {
|
||||
const sourceSessionId = `source-full-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
{ role: "user" as const, content: "first prompt" },
|
||||
@@ -618,19 +588,8 @@ 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,
|
||||
@@ -640,8 +599,6 @@ 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 }),
|
||||
@@ -1796,487 +1753,3 @@ 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,26 +22,14 @@ import {
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import {
|
||||
buildClineSystemPrompt,
|
||||
type ConsecutiveMistakeLimitContext,
|
||||
type ConsecutiveMistakeLimitDecision,
|
||||
formatUserCommandBlock,
|
||||
} from "@cline/shared";
|
||||
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { createDesktopExtensionContext } from "./client-context";
|
||||
import {
|
||||
cancelSidecarMistakeQuestions,
|
||||
emitChunk,
|
||||
nowMs,
|
||||
requestSidecarAskQuestion,
|
||||
sendEvent,
|
||||
} from "./context";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
import type {
|
||||
@@ -413,177 +401,7 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 {
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
|
||||
const workspaceRoot =
|
||||
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
|
||||
@@ -626,8 +444,6 @@ function buildCoreSessionConfig(
|
||||
checkpoint: { enabled: true },
|
||||
sessions: config.sessions,
|
||||
initialMessages: config.initialMessages,
|
||||
extensionContext: createDesktopExtensionContext(telemetryUser),
|
||||
...mistakeRecovery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -858,14 +674,8 @@ 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,
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => startedSessionId),
|
||||
),
|
||||
...buildCoreSessionConfig(request.config),
|
||||
systemPrompt,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
};
|
||||
@@ -887,7 +697,6 @@ 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 });
|
||||
@@ -985,7 +794,6 @@ async function handleAttach(
|
||||
|
||||
async function startRebuiltSession(
|
||||
manager: ClineCore,
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
@@ -997,15 +805,11 @@ async function startRebuiltSession(
|
||||
: undefined;
|
||||
const restarted = await manager.start({
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => sessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
sessionId,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1050,13 +854,11 @@ 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,
|
||||
@@ -1078,7 +880,6 @@ async function rebuildSessionForProviderChange(
|
||||
}
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
ctx,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
previousSystemPrompt,
|
||||
@@ -1195,9 +996,9 @@ async function handleSend(
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
if (session?.attachedViaHub) {
|
||||
// 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.
|
||||
// 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.
|
||||
session.attachedViaHub = false;
|
||||
}
|
||||
if (delivery === "queue") {
|
||||
@@ -1332,7 +1133,6 @@ 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) {
|
||||
@@ -1348,7 +1148,6 @@ 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) {
|
||||
@@ -1490,18 +1289,12 @@ 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,
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => newSessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig({
|
||||
...forkConfig,
|
||||
systemPrompt,
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1518,6 +1311,7 @@ async function handleForkUnlocked(
|
||||
readSessionCheckpointHistory({ metadata: sourceMetadata }),
|
||||
forkBeforeRunCount,
|
||||
) !== undefined;
|
||||
let newSessionId: string;
|
||||
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
|
||||
const cwd =
|
||||
restoreWorkspacePath ||
|
||||
@@ -1560,11 +1354,6 @@ async function handleForkUnlocked(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
);
|
||||
cancelSidecarMistakeQuestions(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
"Session replaced by fork",
|
||||
);
|
||||
ctx.liveSessions.delete(sourceSessionId);
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
@@ -1589,7 +1378,6 @@ 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 ||
|
||||
@@ -1628,8 +1416,6 @@ 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,
|
||||
@@ -1637,14 +1423,10 @@ async function handleRestoreCheckpoint(
|
||||
restore: { messages: true, workspace: true },
|
||||
start: {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig(
|
||||
{
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
},
|
||||
ctx.telemetryUser,
|
||||
createDesktopMistakeRecovery(ctx, () => restoredSessionId),
|
||||
) as unknown as ClineCoreStartConfig,
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
@@ -1656,16 +1438,10 @@ 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,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,7 +6,6 @@ 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 () => {
|
||||
@@ -22,7 +21,6 @@ vi.mock("@cline/core", async () => {
|
||||
executeClineAccountAction: executeClineAccountActionMock,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
saveProviderSettings = persistProviderSettingsMock;
|
||||
},
|
||||
saveLocalProviderSettings: saveProviderSettingsMock,
|
||||
RuntimeOAuthTokenManager: class {
|
||||
@@ -33,13 +31,11 @@ vi.mock("@cline/core", async () => {
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const setDistinctId = vi.fn();
|
||||
const updateCommonProperties = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture, setDistinctId, updateCommonProperties },
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture, setDistinctId, updateCommonProperties };
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const FETCH_ME_ARGS = {
|
||||
@@ -57,14 +53,12 @@ beforeEach(() => {
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
saveProviderSettingsMock.mockReset();
|
||||
persistProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("cline_account command auth states", () => {
|
||||
it("returns a typed not-authenticated result and restores anonymous telemetry when signed out", async () => {
|
||||
const { ctx, capture, setDistinctId, updateCommonProperties } =
|
||||
createContext();
|
||||
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
@@ -78,14 +72,6 @@ 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 () => {
|
||||
@@ -185,7 +171,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("adopts the account identity on login", async () => {
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({
|
||||
@@ -196,62 +182,6 @@ 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 () => {
|
||||
@@ -289,7 +219,7 @@ describe("cline_account keeps feature-flag identity in sync", () => {
|
||||
});
|
||||
|
||||
it("clears the account identity on logout", async () => {
|
||||
const { ctx, setDistinctId, updateCommonProperties } = createContext();
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -303,28 +233,10 @@ 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, setDistinctId, updateCommonProperties } = createContext();
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
|
||||
getProviderSettingsMock.mockReturnValue({});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
|
||||
@@ -347,22 +259,6 @@ 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,9 +15,7 @@ import type {
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
captureAuthRefreshSoftFailure,
|
||||
clearAccountTelemetryIdentity,
|
||||
createConfiguredStreamingTranscriptionSession,
|
||||
createUserInstructionConfigService,
|
||||
ensureCustomProvidersLoaded,
|
||||
@@ -25,17 +23,14 @@ import {
|
||||
fetchClineRecommendedModels,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
identifyAccount,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
parseMcpServerRegistration,
|
||||
persistClineAccountTelemetryIdentity,
|
||||
probeMcpServerConnection,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveClineAccountTelemetryIdentity,
|
||||
resolveLocalClineAuthToken,
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
@@ -71,7 +66,6 @@ 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,
|
||||
@@ -316,23 +310,14 @@ function removePathIfExists(
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
function syncAccountContextFromResult(
|
||||
function syncFeatureFlagsAccountFromResult(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
operation: string,
|
||||
result: unknown,
|
||||
): void {
|
||||
if (operation === "fetchMe") {
|
||||
const user = result as ClineAccountUser | undefined;
|
||||
const user = result as { id?: string; email?: string } | 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 },
|
||||
@@ -342,39 +327,12 @@ function syncAccountContextFromResult(
|
||||
}
|
||||
}
|
||||
|
||||
function syncAccountContextFromSettings(
|
||||
function syncFeatureFlagsAccountFromSettings(
|
||||
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: 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(
|
||||
{},
|
||||
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
}
|
||||
@@ -1873,7 +1831,10 @@ 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.
|
||||
syncSignedOutAccountContext(ctx);
|
||||
void identifyDesktopFeatureFlagsAccount(
|
||||
{},
|
||||
{ logger: ctx.logger, telemetry: ctx.telemetry },
|
||||
);
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
@@ -1886,7 +1847,7 @@ export async function handleCommand(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
syncAccountContextFromResult(ctx, manager, operation, result);
|
||||
syncFeatureFlagsAccountFromResult(ctx, operation, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1929,13 +1890,9 @@ export async function handleCommand(
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
// Known models are merged in unfiltered after the provider's own model
|
||||
// rules run, so including them here would leak e.g. the full OpenAI
|
||||
// catalog into the ChatGPT Subscription (codex) picker.
|
||||
return await getLocalProviderModels(
|
||||
provider,
|
||||
manager.getProviderConfig(provider, { includeKnownModels: false }),
|
||||
String(args?.provider ?? ""),
|
||||
manager.getProviderConfig(String(args?.provider ?? "").trim()),
|
||||
);
|
||||
}
|
||||
if (command === "list_cline_recommended_models") {
|
||||
@@ -2093,7 +2050,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") {
|
||||
syncAccountContextFromSettings(ctx, manager);
|
||||
syncFeatureFlagsAccountFromSettings(ctx, manager);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -1271,215 +1271,3 @@ 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,7 +187,6 @@ function emitChunk(
|
||||
chunk,
|
||||
ts,
|
||||
index: nextIndex,
|
||||
boot: ctx.bootId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,7 +344,6 @@ function handleAgentEvent(
|
||||
message: event.message,
|
||||
noticeType: event.noticeType,
|
||||
reason: event.reason,
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
@@ -369,7 +367,6 @@ function handleAgentEvent(
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Run ended");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -404,19 +401,7 @@ function handleAgentEvent(
|
||||
);
|
||||
break;
|
||||
}
|
||||
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_start":
|
||||
case "iteration_end":
|
||||
break;
|
||||
}
|
||||
@@ -535,7 +520,6 @@ 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;
|
||||
@@ -586,14 +570,12 @@ 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(),
|
||||
@@ -602,7 +584,6 @@ export function createSidecarContext(
|
||||
workspaceRoot,
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
telemetryUser: observability.telemetryUser,
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
@@ -743,28 +724,6 @@ 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 {
|
||||
@@ -865,15 +824,6 @@ 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,7 +14,6 @@ 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,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { version } from "../package.json";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureExtensionActivated: vi.fn(),
|
||||
@@ -29,14 +28,7 @@ vi.mock("@cline/core", async () => {
|
||||
identifyAccount: mocks.identifyAccount,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings() {
|
||||
return {
|
||||
auth: {
|
||||
accountId: "account-1",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
};
|
||||
return { auth: { accountId: "account-1" } };
|
||||
}
|
||||
},
|
||||
setSdkLogger: mocks.setSdkLogger,
|
||||
@@ -65,10 +57,8 @@ describe("desktop observability", () => {
|
||||
|
||||
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
|
||||
metadata: expect.objectContaining({
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline Desktop",
|
||||
platform_version: version,
|
||||
platform: "Cline",
|
||||
}),
|
||||
});
|
||||
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
|
||||
@@ -77,18 +67,9 @@ 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,3 +1,4 @@
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
captureExtensionActivated,
|
||||
createClineTelemetryServiceConfig,
|
||||
@@ -7,11 +8,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
setSdkLogger,
|
||||
} from "@cline/core";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
import {
|
||||
DESKTOP_TELEMETRY_METADATA,
|
||||
resolveDesktopTelemetryUser,
|
||||
} from "./client-context";
|
||||
import { version } from "../package.json";
|
||||
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
|
||||
import {
|
||||
createDesktopLoggerAdapter,
|
||||
@@ -21,7 +18,6 @@ import {
|
||||
export interface DesktopObservability {
|
||||
readonly logger: DesktopLoggerAdapter["core"];
|
||||
readonly telemetry: ITelemetryService;
|
||||
readonly telemetryUser?: UserContext;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -32,23 +28,23 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
|
||||
const telemetryHandle = createConfiguredTelemetryHandle({
|
||||
...createClineTelemetryServiceConfig({
|
||||
metadata: DESKTOP_TELEMETRY_METADATA,
|
||||
metadata: {
|
||||
extension_version: version,
|
||||
cline_type: "desktop",
|
||||
platform: "Cline",
|
||||
platform_version: process.version,
|
||||
os_type: os.platform(),
|
||||
os_version: os.version(),
|
||||
},
|
||||
}),
|
||||
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 });
|
||||
}
|
||||
@@ -58,7 +54,6 @@ export function createDesktopObservability(): DesktopObservability {
|
||||
return {
|
||||
logger,
|
||||
telemetry,
|
||||
telemetryUser,
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -61,11 +60,6 @@ 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. */
|
||||
@@ -121,12 +115,6 @@ 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>;
|
||||
@@ -135,8 +123,6 @@ 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,10 +5,6 @@
|
||||
"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 |
@@ -1,28 +0,0 @@
|
||||
; Tauri's installer only stops the main binary (CheckIfAppIsRunning in its
|
||||
; utils.nsh). The bundled sidecar re-executes itself as the detached Cline Hub
|
||||
; daemon, which by design outlives the app and so keeps code-sidecar.exe
|
||||
; locked. Without this, updating fails with "Error opening file for writing"
|
||||
; (and uninstalling leaves the exe behind) until the user kills that process
|
||||
; by hand.
|
||||
;
|
||||
; Match on the full path rather than the image name: the production Hub is
|
||||
; shared per machine, and a code-sidecar.exe from another install (e.g. the
|
||||
; side-by-side Cline Beta) may be hosting it without locking ours. The path
|
||||
; travels through an environment variable so it never needs quoting inside
|
||||
; the PowerShell command ($INSTDIR contains the username).
|
||||
!macro STOP_SIDECAR_PROCESSES
|
||||
System::Call 'kernel32::SetEnvironmentVariable(t "CLINE_SIDECAR_EXE", t "$INSTDIR\code-sidecar.exe")'
|
||||
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -Command "Get-Process code-sidecar -ErrorAction SilentlyContinue | Where-Object { $$_.Path -eq $$env:CLINE_SIDECAR_EXE } | Stop-Process -Force"`
|
||||
Pop $R0
|
||||
; TerminateProcess returns before the file handle is released; same wait
|
||||
; Tauri uses after killing the main binary.
|
||||
Sleep 500
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
!insertmacro STOP_SIDECAR_PROCESSES
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
!insertmacro STOP_SIDECAR_PROCESSES
|
||||
!macroend
|
||||
@@ -9,8 +9,7 @@ use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -284,16 +283,21 @@ async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
|
||||
struct DesktopBackendState {
|
||||
ws_endpoint: Mutex<Option<String>>,
|
||||
process: Mutex<Option<Child>>,
|
||||
shutting_down: AtomicBool,
|
||||
shutting_down: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl DesktopBackendState {
|
||||
fn is_shutting_down(&self) -> bool {
|
||||
self.shutting_down.load(AtomicOrdering::Acquire)
|
||||
self.shutting_down
|
||||
.lock()
|
||||
.map(|guard| *guard)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.shutting_down.store(true, AtomicOrdering::Release);
|
||||
if let Ok(mut guard) = self.shutting_down.lock() {
|
||||
*guard = true;
|
||||
}
|
||||
|
||||
if let Ok(mut process_guard) = self.process.lock() {
|
||||
if let Some(child) = process_guard.as_mut() {
|
||||
@@ -507,26 +511,10 @@ 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 process_guard = state
|
||||
let mut 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
|
||||
@@ -679,29 +667,17 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_desktop_backend_endpoint(
|
||||
fn get_desktop_backend_endpoint(
|
||||
backend_state: State<'_, Arc<DesktopBackendState>>,
|
||||
context: State<'_, AppContext>,
|
||||
) -> Result<String, String> {
|
||||
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}"))??;
|
||||
|
||||
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
|
||||
// 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
|
||||
@@ -724,7 +700,7 @@ async fn get_desktop_backend_endpoint(
|
||||
if child_exited {
|
||||
return Err("desktop backend exited before publishing its endpoint".to_string());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err("desktop backend endpoint not ready".to_string())
|
||||
}
|
||||
@@ -813,82 +789,56 @@ 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 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)
|
||||
}
|
||||
/// 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"];
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_ICONS.contains(&icon.as_str()) {
|
||||
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
|
||||
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
|
||||
return Err(format!("unknown app icon: {icon}"));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 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();
|
||||
// "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()
|
||||
));
|
||||
}
|
||||
app.run_on_main_thread(move || {
|
||||
use objc2::{AllocAnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSApplication, NSImage};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
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);
|
||||
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)) };
|
||||
})
|
||||
.map_err(|e| format!("failed switching app icon: {e}"))?;
|
||||
result_rx
|
||||
.await
|
||||
.map_err(|_| "app icon update ended before AppKit completed".to_string())??;
|
||||
.map_err(|e| format!("failed switching dock icon: {e}"))?;
|
||||
Ok(true)
|
||||
}
|
||||
#[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")))]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(false)
|
||||
@@ -1240,15 +1190,9 @@ fn main() {
|
||||
setup_tray_icon(app)?;
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
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}");
|
||||
}
|
||||
});
|
||||
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
||||
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) {
|
||||
@@ -1499,28 +1443,6 @@ 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.25",
|
||||
"version": "0.0.22",
|
||||
"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/app/*.png"],
|
||||
"resources": ["icons/dock/*.png"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "nsis/installer-hooks.nsh"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,6 @@
|
||||
|
||||
@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 {
|
||||
@@ -63,17 +53,6 @@
|
||||
-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,7 +89,6 @@ import {
|
||||
type SessionHistoryItem,
|
||||
type SessionMetadata,
|
||||
} from "@/lib/session-history";
|
||||
import { readImportedFromTool } from "@/lib/session-import";
|
||||
import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
@@ -218,8 +217,8 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// The native app icon reverts to the bundled icon every launch; re-apply
|
||||
// the user's choice once the shell is up.
|
||||
// The dock reverts to the bundled icon every launch; re-apply the
|
||||
// user's choice once the shell is up.
|
||||
void syncAppIcon();
|
||||
}, []);
|
||||
|
||||
@@ -602,7 +601,6 @@ function ChatThreadPane({
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
activityLabel,
|
||||
config,
|
||||
messages,
|
||||
error,
|
||||
@@ -1082,23 +1080,6 @@ function ChatThreadPane({
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (prompt: string) => {
|
||||
const trimmed = prompt.trim();
|
||||
@@ -1112,23 +1093,9 @@ function ChatThreadPane({
|
||||
setPromptInput("");
|
||||
const toSend = [...pendingAttachments];
|
||||
setPendingAttachments([]);
|
||||
const promptTaken = await sendPrompt(trimmed, toSend);
|
||||
// The prompt never reached the runtime (e.g. the provider connection
|
||||
// failed): hand it back so the user can fix the provider and resend
|
||||
// without retyping. Leave anything they typed meanwhile alone.
|
||||
if (!promptTaken && promptInputRef.current.trim() === "") {
|
||||
setPromptInput(trimmed);
|
||||
handleAttachFiles(toSend);
|
||||
}
|
||||
await sendPrompt(trimmed, toSend);
|
||||
},
|
||||
[
|
||||
handleAttachFiles,
|
||||
onThreadStarted,
|
||||
pendingAttachments,
|
||||
sendPrompt,
|
||||
setPromptInput,
|
||||
threadId,
|
||||
],
|
||||
[onThreadStarted, pendingAttachments, sendPrompt, setPromptInput, threadId],
|
||||
);
|
||||
|
||||
const handleReasoningChange = useCallback(
|
||||
@@ -1303,6 +1270,23 @@ function ChatThreadPane({
|
||||
setPromptInput,
|
||||
]);
|
||||
|
||||
const handleAttachFiles = useCallback((files: File[]) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const existing = new Set(
|
||||
prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`),
|
||||
);
|
||||
const next = [...prev];
|
||||
for (const file of files) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key);
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const attachmentList = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
@@ -1389,9 +1373,6 @@ 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
|
||||
@@ -1613,9 +1594,7 @@ function ChatThreadPane({
|
||||
onApproveToolApproval={handleApproveToolApproval}
|
||||
onRejectToolApproval={handleRejectToolApproval}
|
||||
chatTransportState={chatTransportState}
|
||||
activityLabel={activityLabel}
|
||||
error={displayedError}
|
||||
importedFromTool={importedFromTool}
|
||||
messages={displayedMessages}
|
||||
onEditMessage={handleEditMessage}
|
||||
onRestoreCheckpoint={handleRestoreCheckpoint}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Import,
|
||||
Loader2,
|
||||
Mic,
|
||||
PanelLeftOpen,
|
||||
@@ -146,7 +145,6 @@ const SETTINGS_SECTION_ICONS = {
|
||||
Voice: Mic,
|
||||
Channels: Radio,
|
||||
Schedules: Clock3,
|
||||
Import: Import,
|
||||
Account: CircleUserRound,
|
||||
Customize: Blocks,
|
||||
Marketplace: Store,
|
||||
|
||||
@@ -15,7 +15,6 @@ 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,13 +4,11 @@ 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,
|
||||
@@ -21,7 +19,6 @@ const {
|
||||
loadProviderModelsMock,
|
||||
speechInputMockState,
|
||||
startVercelStreamingTranscriptionMock,
|
||||
subscribeToProviderCatalogInvalidationMock,
|
||||
subscribeToProviderModelsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
loadProviderModelCatalogMock: vi.fn(),
|
||||
@@ -30,14 +27,7 @@ const {
|
||||
current: null as MockSpeechInputProps | null,
|
||||
},
|
||||
startVercelStreamingTranscriptionMock: vi.fn(),
|
||||
subscribeToProviderCatalogInvalidationMock: vi.fn<
|
||||
(listener: () => void) => () => void
|
||||
>(() => vi.fn()),
|
||||
subscribeToProviderModelsMock: vi.fn<
|
||||
(
|
||||
listener: (providerId: string, models: ProviderModel[]) => void,
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
subscribeToProviderModelsMock: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
type MockSpeechInputProps = {
|
||||
@@ -82,8 +72,6 @@ vi.mock("@/components/ai-elements/speech-input", async () => {
|
||||
vi.mock("@/lib/provider-model-catalog", () => ({
|
||||
loadProviderModelCatalog: loadProviderModelCatalogMock,
|
||||
loadProviderModels: loadProviderModelsMock,
|
||||
subscribeToProviderCatalogInvalidation:
|
||||
subscribeToProviderCatalogInvalidationMock,
|
||||
subscribeToProviderModels: subscribeToProviderModelsMock,
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT: "cline:test-voice-input-settings-changed",
|
||||
}));
|
||||
@@ -99,7 +87,6 @@ beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
loadProviderModelCatalogMock.mockReset().mockResolvedValue({
|
||||
providers: [],
|
||||
configuredProviderIds: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: [] },
|
||||
@@ -113,9 +100,6 @@ beforeEach(() => {
|
||||
cancel: vi.fn(),
|
||||
});
|
||||
subscribeToProviderModelsMock.mockReset().mockReturnValue(vi.fn());
|
||||
subscribeToProviderCatalogInvalidationMock
|
||||
.mockReset()
|
||||
.mockReturnValue(vi.fn());
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
@@ -152,7 +136,6 @@ function providerCatalog(
|
||||
) {
|
||||
return {
|
||||
providers: [],
|
||||
configuredProviderIds: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: [] },
|
||||
@@ -834,7 +817,7 @@ describe("ChatInputBar", () => {
|
||||
subscribeToProviderModelsMock.mock.calls[0]?.[0];
|
||||
await act(async () => {
|
||||
providerModelsListener?.("cline", [
|
||||
{ id: "test-model", name: "Refreshed model" },
|
||||
{ id: "refreshed-model", name: "Refreshed model" },
|
||||
]);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
@@ -954,7 +937,6 @@ describe("ChatInputBar", () => {
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
configuredProviderIds: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: { cline: ["test-model"] },
|
||||
providerReasoningModels: { cline: ["test-model"] },
|
||||
@@ -1283,7 +1265,6 @@ describe("ChatInputBar", () => {
|
||||
);
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
configuredProviderIds: [],
|
||||
enabledProviderIds: ["openrouter"],
|
||||
providerModels: {
|
||||
openrouter: ["old-session-model", "user-picked-model"],
|
||||
@@ -1383,7 +1364,6 @@ describe("ChatInputBar", () => {
|
||||
it("renders the cline model picker with recommended and free sections", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
configuredProviderIds: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
providerModels: {
|
||||
cline: [
|
||||
@@ -1482,91 +1462,6 @@ describe("ChatInputBar", () => {
|
||||
expect(optionLabels[2]).toContain("Other Model");
|
||||
});
|
||||
|
||||
it("marks configured providers in the picker and refreshes on catalog invalidation", async () => {
|
||||
const catalog = {
|
||||
providers: [],
|
||||
configuredProviderIds: ["anthropic"],
|
||||
enabledProviderIds: ["cline", "anthropic"],
|
||||
providerModels: { cline: ["test-model"], anthropic: ["claude"] },
|
||||
providerNames: { cline: "Cline", anthropic: "Anthropic" },
|
||||
providerReasoningModels: {},
|
||||
};
|
||||
loadProviderModelCatalogMock.mockResolvedValue(catalog);
|
||||
let invalidate!: () => void;
|
||||
subscribeToProviderCatalogInvalidationMock.mockImplementation(
|
||||
(listener) => {
|
||||
invalidate = listener;
|
||||
return vi.fn();
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking={false}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const providerTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(providerTrigger?.textContent).toContain("Cline");
|
||||
});
|
||||
// The trigger never carries the status; only list rows do.
|
||||
expect(providerTrigger?.querySelector('[aria-label="Configured"]')).toBe(
|
||||
null,
|
||||
);
|
||||
const configuredRows = () =>
|
||||
[...document.querySelectorAll('[role="option"]')]
|
||||
.filter((option) => option.querySelector('[aria-label="Configured"]'))
|
||||
.map((option) => option.textContent);
|
||||
|
||||
await act(async () => providerTrigger?.click());
|
||||
expect(configuredRows()).toEqual(["Anthropic"]);
|
||||
|
||||
// Credentials saved in settings invalidate the shared catalog; the open
|
||||
// picker must reflect the new readiness without a remount.
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
...catalog,
|
||||
configuredProviderIds: ["cline", "anthropic"],
|
||||
});
|
||||
await act(async () => invalidate());
|
||||
await vi.waitFor(() => {
|
||||
expect(configuredRows()).toEqual(["Cline", "Anthropic"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline-pass picker offer", () => {
|
||||
const renderComposer = async (props: {
|
||||
model: string;
|
||||
@@ -1623,7 +1518,6 @@ describe("ChatInputBar", () => {
|
||||
// picker hides while the subscribed tier is non-empty.
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
configuredProviderIds: [],
|
||||
enabledProviderIds: ["cline", "cline-pass"],
|
||||
providerModels: {
|
||||
cline: ["test-model"],
|
||||
@@ -1657,186 +1551,6 @@ 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: [],
|
||||
configuredProviderIds: [],
|
||||
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,
|
||||
@@ -1868,25 +1582,6 @@ 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({
|
||||
|
||||
@@ -5,15 +5,7 @@ import {
|
||||
formatDisplayUserInput,
|
||||
} from "@cline/shared/browser";
|
||||
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
|
||||
import {
|
||||
ArrowUp,
|
||||
Brain,
|
||||
CircleCheck,
|
||||
CircleStop,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
SpeechInput,
|
||||
@@ -53,7 +45,6 @@ import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import {
|
||||
loadProviderModelCatalog,
|
||||
loadProviderModels,
|
||||
subscribeToProviderCatalogInvalidation,
|
||||
subscribeToProviderModels,
|
||||
type TranscriptionModelTarget,
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
|
||||
@@ -1563,9 +1554,6 @@ const ModelSelector = memo(function ModelSelector({
|
||||
"loading" | "catalog" | "fallback"
|
||||
>("loading");
|
||||
const [enabledProviderIds, setEnabledProviderIds] = useState<string[]>([]);
|
||||
const [configuredProviderIds, setConfiguredProviderIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [providerNames, setProviderNames] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
@@ -1630,29 +1618,21 @@ const ModelSelector = memo(function ModelSelector({
|
||||
[modelPicker],
|
||||
);
|
||||
const resolvedModel = useMemo(() => {
|
||||
if (modelsForProvider.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const rememberedModel =
|
||||
lastSelection.lastModelByProvider[resolvedProvider] ??
|
||||
(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))
|
||||
) {
|
||||
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)) {
|
||||
return model;
|
||||
}
|
||||
// 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))
|
||||
) {
|
||||
if (rememberedModel && pickerModelIds.has(rememberedModel)) {
|
||||
return rememberedModel;
|
||||
}
|
||||
return (
|
||||
@@ -1664,7 +1644,6 @@ const ModelSelector = memo(function ModelSelector({
|
||||
lastSelection.lastModelByProvider,
|
||||
model,
|
||||
modelsForProvider,
|
||||
normalizedProvider,
|
||||
pickerModelIds,
|
||||
rememberedLastProvider,
|
||||
resolvedProvider,
|
||||
@@ -1672,8 +1651,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 or missing from the catalog. Surface it
|
||||
// under its own section so the selected value always exists in the list.
|
||||
// model outside the current offer. Surface it under its own section
|
||||
// rather than selecting a value that does not exist in the list.
|
||||
const visibleModelPicker = useMemo((): ModelPickerData => {
|
||||
if (!resolvedModel || pickerModelIds.has(resolvedModel)) {
|
||||
return modelPicker;
|
||||
@@ -1729,7 +1708,6 @@ const ModelSelector = memo(function ModelSelector({
|
||||
...(payload.providerModelDetails ?? {}),
|
||||
}));
|
||||
setReasoningCapabilitySource("catalog");
|
||||
setConfiguredProviderIds(payload.configuredProviderIds);
|
||||
setEnabledProviderIds((current) => {
|
||||
const nextProviderIds = new Set(payload.enabledProviderIds);
|
||||
if (normalizedProvider) {
|
||||
@@ -1810,26 +1788,6 @@ const ModelSelector = memo(function ModelSelector({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Credentials saved or removed in settings (or OAuth completing) invalidate
|
||||
// the shared catalog; refetch so the readiness indicators don't go stale
|
||||
// while the composer stays mounted.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const unsubscribe = subscribeToProviderCatalogInvalidation(() => {
|
||||
loadProviderModelCatalog()
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setConfiguredProviderIds(payload.configuredProviderIds);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The remembered selection (what new sessions default to) is only written
|
||||
// from the explicit picker handlers below. Mirroring every provider/model
|
||||
// prop change here would also capture passive changes — most notably
|
||||
@@ -1922,15 +1880,14 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
// Preserve live-only remembered models missing from the bundled
|
||||
// catalog. Only fall back when a known model is hidden by the offer.
|
||||
// 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.
|
||||
const providerOptionIds = new Set(
|
||||
pickerDataForProvider(value).options.map((option) => option.value),
|
||||
);
|
||||
const nextModel =
|
||||
rememberedModel &&
|
||||
(providerOptionIds.has(rememberedModel) ||
|
||||
!providerModelIds.includes(rememberedModel))
|
||||
rememberedModel && providerOptionIds.has(rememberedModel)
|
||||
? rememberedModel
|
||||
: (providerModelIds.find((id) => providerOptionIds.has(id)) ??
|
||||
providerModelIds[0]);
|
||||
@@ -1956,25 +1913,13 @@ const ModelSelector = memo(function ModelSelector({
|
||||
},
|
||||
[onModelChange, rememberSelection, resolvedProvider],
|
||||
);
|
||||
// Enabled providers can lack usable credentials (e.g. entries seeded by
|
||||
// legacy migration), so mark the ones that are actually ready for a turn.
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
providers.map((value) => ({
|
||||
...(configuredProviderIds.includes(value)
|
||||
? {
|
||||
indicator: (
|
||||
<CircleCheck
|
||||
aria-label="Configured"
|
||||
className="size-3 shrink-0 text-emerald-500"
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
label: providerNames[value]?.trim() || value,
|
||||
value,
|
||||
})),
|
||||
[configuredProviderIds, providerNames, providers],
|
||||
[providerNames, providers],
|
||||
);
|
||||
const selectedModelLabel =
|
||||
visibleModelPicker.options.find((option) => option.value === resolvedModel)
|
||||
@@ -2000,7 +1945,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
<SearchCombobox
|
||||
ariaLabel="Model"
|
||||
className={triggerClassName}
|
||||
disabled={isBusy || visibleModelPicker.options.length === 0}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyText="No models found."
|
||||
onValueChange={(value) => {
|
||||
handleModelSelect(value);
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("ChatMessages tool disclosures", () => {
|
||||
}),
|
||||
createdAt: 1,
|
||||
};
|
||||
await renderMessages([pendingTool], { status: "running" });
|
||||
await renderMessages([pendingTool]);
|
||||
|
||||
const pendingTitle = container.querySelector(
|
||||
".cline-chat-tool-label > span",
|
||||
@@ -162,93 +162,6 @@ 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([
|
||||
{
|
||||
@@ -2204,25 +2117,4 @@ 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,9 +26,7 @@ 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,
|
||||
@@ -36,7 +34,6 @@ import {
|
||||
collapseCompletedWork,
|
||||
getThoughtDurationMilliseconds,
|
||||
groupChatMessages,
|
||||
isSystemSteeringMessage,
|
||||
} from "./messages/group-messages";
|
||||
import { ChatImageLightbox } from "./messages/image-lightbox";
|
||||
import { MessageBubble } from "./messages/message-bubble";
|
||||
@@ -60,10 +57,6 @@ 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[];
|
||||
@@ -106,8 +99,6 @@ function ChatMessagesImpl({
|
||||
isSessionSwitching = false,
|
||||
messages,
|
||||
error,
|
||||
importedFromTool,
|
||||
activityLabel = null,
|
||||
streamingMessageId = null,
|
||||
pendingToolApprovals,
|
||||
pendingAskQuestions,
|
||||
@@ -214,14 +205,6 @@ 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
|
||||
@@ -549,9 +532,6 @@ 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
|
||||
@@ -563,9 +543,6 @@ function ChatMessagesImpl({
|
||||
if (child.type === "tools") {
|
||||
return (
|
||||
<ToolMessageBlock
|
||||
isRunActive={
|
||||
isRunActive && itemIndex > lastUserItemIndex
|
||||
}
|
||||
key={`tools_${child.messages[0]?.id ?? "empty"}`}
|
||||
messages={child.messages}
|
||||
onExpandImage={handleExpandImage}
|
||||
@@ -699,9 +676,7 @@ function ChatMessagesImpl({
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className={STREAMING_TITLE_CLASS}>
|
||||
{activityLabel ?? "Thinking..."}
|
||||
</span>
|
||||
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"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,20 +61,15 @@ 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
|
||||
@@ -108,7 +103,7 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
const toolSessionId = message.sessionId;
|
||||
const toolCallId = message.meta?.toolCallId;
|
||||
const canProceed = Boolean(
|
||||
isRunning &&
|
||||
inProgress &&
|
||||
isCommand &&
|
||||
message.meta?.toolDetachable === true &&
|
||||
toolSessionId &&
|
||||
@@ -221,9 +216,9 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={<ToolLabel isRunning={isRunning} parts={labelParts} />}
|
||||
label={<ToolLabel isRunning={inProgress} parts={labelParts} />}
|
||||
showDisclosureIcon={false}
|
||||
status={hasError ? "error" : isRunning ? "running" : "success"}
|
||||
status={hasError ? "error" : inProgress ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent presentation="rail">
|
||||
{details.length > 0 ? (
|
||||
@@ -258,7 +253,10 @@ const ToolCallRow = memo(function ToolCallRow({
|
||||
),
|
||||
)}
|
||||
{commandOutput ? (
|
||||
<CommandOutputTerminal isRunning={isRunning} output={commandOutput} />
|
||||
<CommandOutputTerminal
|
||||
isRunning={inProgress}
|
||||
output={commandOutput}
|
||||
/>
|
||||
) : submitText ? (
|
||||
// The summary is the run's final answer: full foreground color,
|
||||
// not the panel's muted tool-detail gray.
|
||||
@@ -393,12 +391,10 @@ function CommandOutputTerminal({
|
||||
export const ToolMessageBlock = memo(
|
||||
function ToolMessageBlock({
|
||||
messages,
|
||||
isRunActive,
|
||||
onExpandImage,
|
||||
onProceedWhileRunning,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isRunActive: boolean;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onProceedWhileRunning?: ProceedWhileRunningHandler;
|
||||
}) {
|
||||
@@ -407,7 +403,6 @@ export const ToolMessageBlock = memo(
|
||||
<div className="flex flex-col gap-1">
|
||||
{messages.map((message) => (
|
||||
<ToolCallRow
|
||||
isRunActive={isRunActive}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onExpandImage={onExpandImage}
|
||||
@@ -418,7 +413,6 @@ 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,7 +43,6 @@ function renderView({
|
||||
openThread = vi.fn(),
|
||||
loadAllSessions = vi.fn(async () => true),
|
||||
loadOlderSessions = vi.fn(),
|
||||
requestUsage = vi.fn(),
|
||||
mayHaveMoreSessions = false,
|
||||
threads = [thread],
|
||||
hasLoadedHistory = true,
|
||||
@@ -51,7 +50,6 @@ 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;
|
||||
@@ -67,7 +65,6 @@ function renderView({
|
||||
openThread,
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
requestUsage,
|
||||
setThreadPinned: vi.fn(),
|
||||
sessionById: new Map(
|
||||
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
|
||||
@@ -79,7 +76,6 @@ function renderView({
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
openThread,
|
||||
requestUsage,
|
||||
render: () =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
@@ -312,33 +308,6 @@ 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,24 +241,6 @@ 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(() => {
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ImportContent } from "./import-view";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke, subscribe: vi.fn(() => () => {}) },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/import-sessions-dialog", () => ({
|
||||
ImportSessionsDialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="import-dialog">
|
||||
<button onClick={() => onOpenChange(false)} type="button">
|
||||
stub-close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
function session(tool: string, alreadyImportedSessionId?: string) {
|
||||
return {
|
||||
tool,
|
||||
sourceId: `${tool}-${Math.random()}`,
|
||||
sourcePath: "/tmp/x",
|
||||
title: "t",
|
||||
cwd: "/tmp",
|
||||
startedAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
messageCount: 1,
|
||||
alreadyImportedSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invoke.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
await act(async () => {
|
||||
root.render(<ImportContent />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function click(label: string) {
|
||||
const button = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes(label),
|
||||
);
|
||||
if (!button) throw new Error(`no button "${label}"`);
|
||||
act(() => button.click());
|
||||
}
|
||||
|
||||
describe("ImportContent", () => {
|
||||
it("summarizes each tool from the scan", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
installedTools: ["claude-code", "codex"],
|
||||
sessions: [
|
||||
session("claude-code"),
|
||||
session("claude-code", "existing"),
|
||||
session("claude-code"),
|
||||
],
|
||||
});
|
||||
await render();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"list_importable_sessions",
|
||||
{},
|
||||
expect.anything(),
|
||||
);
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Claude Code");
|
||||
expect(text).toContain("3 sessions found · 1 already imported");
|
||||
expect(text).toContain("No sessions found");
|
||||
expect(text).toContain("Not detected on this machine");
|
||||
});
|
||||
|
||||
it("opens the import dialog and rescans when it closes", async () => {
|
||||
invoke.mockResolvedValue({ installedTools: [], sessions: [] });
|
||||
await render();
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
|
||||
click("Import sessions");
|
||||
expect(container.querySelector("[data-testid=import-dialog]")).not.toBe(
|
||||
null,
|
||||
);
|
||||
|
||||
click("stub-close");
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector("[data-testid=import-dialog]")).toBe(null);
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores a slow earlier scan that resolves after a rescan", async () => {
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
invoke
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
installedTools: ["codex"],
|
||||
sessions: [session("codex", "existing")],
|
||||
});
|
||||
await render();
|
||||
expect(container.textContent).toContain("Scanning…");
|
||||
|
||||
click("Import sessions");
|
||||
click("stub-close");
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"1 session found · 1 already imported",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst({ installedTools: ["codex"], sessions: [session("codex")] });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"1 session found · 1 already imported",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a failed scan", async () => {
|
||||
invoke.mockRejectedValue(new Error("disk on fire"));
|
||||
await render();
|
||||
expect(container.textContent).toContain(
|
||||
"Couldn't scan for sessions: disk on fire",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Import } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ImportSessionsDialog } from "@/components/import-sessions-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
type ListImportableSessionsResponse,
|
||||
SESSION_IMPORT_TOOL_LABELS,
|
||||
SESSION_IMPORT_TOOL_ORDER,
|
||||
type SessionImportTool,
|
||||
} from "@/lib/session-import";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
function toolStatus(
|
||||
tool: SessionImportTool,
|
||||
scan: ListImportableSessionsResponse,
|
||||
): string {
|
||||
if (!scan.installedTools.includes(tool)) {
|
||||
return "Not detected on this machine";
|
||||
}
|
||||
const sessions = scan.sessions.filter((session) => session.tool === tool);
|
||||
if (sessions.length === 0) return "No sessions found";
|
||||
const imported = sessions.filter(
|
||||
(session) => session.alreadyImportedSessionId,
|
||||
).length;
|
||||
const found = `${sessions.length} session${sessions.length === 1 ? "" : "s"} found`;
|
||||
return imported > 0 ? `${found} · ${imported} already imported` : found;
|
||||
}
|
||||
|
||||
export function ImportContent() {
|
||||
const [scan, setScan] = useState<ListImportableSessionsResponse | null>(null);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// A slow initial scan can finish after the post-import rescan; only the
|
||||
// latest request may update the counts.
|
||||
const scanRequestRef = useRef(0);
|
||||
|
||||
const rescan = useCallback(async () => {
|
||||
const requestId = ++scanRequestRef.current;
|
||||
setScanError(null);
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<ListImportableSessionsResponse>(
|
||||
"list_importable_sessions",
|
||||
{},
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
if (scanRequestRef.current !== requestId) return;
|
||||
setScan({
|
||||
installedTools: response.installedTools ?? [],
|
||||
sessions: response.sessions ?? [],
|
||||
});
|
||||
} catch (error) {
|
||||
if (scanRequestRef.current !== requestId) return;
|
||||
setScanError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void rescan();
|
||||
}, [rescan]);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button onClick={() => setDialogOpen(true)} type="button">
|
||||
<Import className="size-4" />
|
||||
Import sessions
|
||||
</Button>
|
||||
}
|
||||
description="Bring your conversation history from other coding tools into Cline. Imported sessions show up in your history and can be continued here."
|
||||
title="Import"
|
||||
/>
|
||||
<section className="max-w-2xl">
|
||||
{SESSION_IMPORT_TOOL_ORDER.map((tool, index) => (
|
||||
<div
|
||||
className={
|
||||
index === 0
|
||||
? "flex items-center justify-between gap-5 border-y py-4"
|
||||
: "flex items-center justify-between gap-5 border-b py-4"
|
||||
}
|
||||
key={tool}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{SESSION_IMPORT_TOOL_LABELS[tool]}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scan
|
||||
? toolStatus(tool, scan)
|
||||
: scanError
|
||||
? "Scan failed"
|
||||
: "Scanning…"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{scanError ? (
|
||||
<p className="mt-4 text-sm text-destructive" role="alert">
|
||||
Couldn't scan for sessions: {scanError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
<ImportSessionsDialog
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
// Counts reflect whatever the dialog just imported.
|
||||
if (!open) void rescan();
|
||||
}}
|
||||
open={dialogOpen}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ const ALL_SETTINGS_SECTIONS = [
|
||||
"Voice",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Import",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { providerOffersModelTool } from "@cline/llms/browser";
|
||||
import { Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { Import, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ImportSessionsDialog } from "@/components/import-sessions-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
APP_ICONS,
|
||||
type AppIconId,
|
||||
appIconAssetPath,
|
||||
appIconSurface,
|
||||
DEFAULT_APP_ICON,
|
||||
readStoredAppIcon,
|
||||
setStoredAppIcon,
|
||||
@@ -66,7 +66,6 @@ import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { CustomizeView } from "./customize-view";
|
||||
import { ImportContent } from "./import-view";
|
||||
import { NotificationSettings } from "./notification-settings";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
@@ -623,8 +622,6 @@ export function SettingsView({
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent onOpenSession={onOpenSession} />
|
||||
) : activeNav === "Import" ? (
|
||||
<ImportContent />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
@@ -669,6 +666,7 @@ function GeneralSettingsContent({
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [accent, setAccent] = useState<HubAccent>(() => {
|
||||
if (typeof window === "undefined") return "violet";
|
||||
return readStoredHubAccent();
|
||||
@@ -681,9 +679,6 @@ 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);
|
||||
@@ -706,7 +701,6 @@ function GeneralSettingsContent({
|
||||
>(null);
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
|
||||
useEffect(() => subscribeToAppFontSize(setFontSize), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -890,6 +884,9 @@ 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(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -999,7 +996,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 {appIconLocation}.
|
||||
Pick the icon Cline shows in the Dock.
|
||||
</p>
|
||||
{appIconError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
@@ -1129,6 +1126,31 @@ function GeneralSettingsContent({
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Import sessions
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bring your conversation history from Claude Code, Codex, or
|
||||
opencode into Cline.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Import className="size-3" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
<ImportSessionsDialog
|
||||
onOpenChange={setImportDialogOpen}
|
||||
open={importDialogOpen}
|
||||
/>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// @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,42 +2,18 @@
|
||||
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } 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);
|
||||
@@ -45,9 +21,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
delete (window as Window & { __TAURI_INTERNALS__?: unknown })
|
||||
.__TAURI_INTERNALS__;
|
||||
vi.unstubAllGlobals();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -83,115 +56,6 @@ 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)));
|
||||
|
||||
@@ -199,7 +63,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-(--window-title-bar-height)");
|
||||
expect(titleBar?.className).toContain("h-12");
|
||||
expect(titleBar?.className).toContain("shrink-0");
|
||||
expect(titleBar?.nextElementSibling).toBe(page);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Minus, Square, X } from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { isTauriAvailable } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WindowTitleBarContextValue = {
|
||||
@@ -40,92 +31,10 @@ 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
|
||||
@@ -147,10 +56,7 @@ export function WindowTitleBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"isolate h-(--window-title-bar-height) shrink-0",
|
||||
className,
|
||||
)}
|
||||
className={cn("isolate h-12 shrink-0 max-md:h-7", className)}
|
||||
data-slot="window-title-bar"
|
||||
data-tauri-drag-region="deep"
|
||||
>
|
||||
|
||||
@@ -47,21 +47,6 @@ describe("resolveCredentialError", () => {
|
||||
expect(resolveCredentialError(makeConfig({ provider }))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"claude-code",
|
||||
"openai-codex-cli",
|
||||
])("allows local-auth provider %s without an API key", (provider) => {
|
||||
// Local CLI providers authenticate from the CLI's own credential
|
||||
// store; the catalog marks them `local-auth` and the key is inert.
|
||||
expect(resolveCredentialError(makeConfig({ provider }))).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a catalog-declared OAuth provider outside the fallback id set", () => {
|
||||
expect(
|
||||
resolveCredentialError(makeConfig({ provider: "opencode" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("treats provider ids case-insensitively", () => {
|
||||
expect(
|
||||
resolveCredentialError(makeConfig({ provider: "Cline-Pass" })),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getProviderCollectionSync } from "@cline/llms/browser";
|
||||
import {
|
||||
createSessionId,
|
||||
type GeneratedMedia,
|
||||
@@ -9,7 +8,6 @@ import type {
|
||||
ChatSessionConfig,
|
||||
ChatSessionStatus,
|
||||
} from "@/lib/chat-schema";
|
||||
import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import type { SessionHistoryStatus } from "@/lib/session-history";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "./constants";
|
||||
|
||||
@@ -173,14 +171,6 @@ export function resolveCredentialError(
|
||||
if (OAUTH_MANAGED_PROVIDERS.has(providerId)) {
|
||||
return null;
|
||||
}
|
||||
// OAuth and local-auth providers (Claude Code, Codex CLI) keep their
|
||||
// credentials outside the webview config and never read an API key.
|
||||
const capabilities = getProviderCollectionSync(
|
||||
normalizeProviderId(providerId),
|
||||
)?.provider.capabilities;
|
||||
if (capabilities?.includes("oauth") || capabilities?.includes("local-auth")) {
|
||||
return null;
|
||||
}
|
||||
if (config.apiKey.trim().length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,6 @@ 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,10 +8,6 @@ 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(() => ({
|
||||
@@ -112,113 +108,6 @@ 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 {
|
||||
@@ -949,68 +838,6 @@ describe("useChatSession", () => {
|
||||
).toContain(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// The sidecar synthesizes a messages-less error result when the runtime
|
||||
// threw before the turn began (e.g. the Codex OAuth refresh failed on a
|
||||
// provider switch): the prompt never entered the session, so it must be
|
||||
// handed back to the composer instead of vanishing.
|
||||
{
|
||||
label: "hands the prompt back when the runtime threw before the turn",
|
||||
messages: undefined,
|
||||
expectedTaken: false,
|
||||
expectedUserMessages: 0,
|
||||
},
|
||||
// A run that failed mid-turn persisted the user turn; the prompt stays
|
||||
// in the transcript and must not be duplicated into the composer.
|
||||
{
|
||||
label: "keeps the prompt when the run failed mid-turn",
|
||||
messages: [{ role: "user", content: "Rebase the branch" }],
|
||||
expectedTaken: true,
|
||||
expectedUserMessages: 1,
|
||||
},
|
||||
])("$label", async ({ messages, expectedTaken, expectedUserMessages }) => {
|
||||
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") {
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
finishReason: "error",
|
||||
text: "Token refresh failed: 401 - Could not validate your refresh token.",
|
||||
messages,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
let taken: boolean | undefined;
|
||||
await act(async () => {
|
||||
taken = await current.sendPrompt("Rebase the branch");
|
||||
});
|
||||
|
||||
expect(taken).toBe(expectedTaken);
|
||||
expect(current.status).toBe("failed");
|
||||
expect(
|
||||
current.messages.filter((message) => message.role === "user"),
|
||||
).toHaveLength(expectedUserMessages);
|
||||
expect(
|
||||
current.messages.findLast((message) => message.role === "error")?.content,
|
||||
).toContain("Token refresh failed: 401");
|
||||
});
|
||||
|
||||
it("publishes the first user message before cold session startup resolves", async () => {
|
||||
let resolveStart: ((value: { sessionId: string }) => void) | undefined;
|
||||
const startResponse = new Promise<{ sessionId: string }>((resolve) => {
|
||||
@@ -1598,7 +1425,7 @@ describe("useChatSession", () => {
|
||||
expect(userMessages[1]?.id).toBe("queued_user_queued-prompt-2");
|
||||
});
|
||||
|
||||
it("stamps live rows on the webview clock rather than the sidecar timestamp", async () => {
|
||||
it("keeps live stream timestamps in milliseconds", async () => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
@@ -1630,9 +1457,7 @@ describe("useChatSession", () => {
|
||||
);
|
||||
expect(chatEventHandler).toBeDefined();
|
||||
expect(userMessage).toBeDefined();
|
||||
// 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();
|
||||
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) + 5_000;
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler?.({
|
||||
@@ -1644,12 +1469,10 @@ describe("useChatSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const assistantCreatedAt = current.messages.find(
|
||||
(message) => message.role === "assistant",
|
||||
)?.createdAt;
|
||||
expect(assistantCreatedAt).not.toBe(thinkingTimestamp);
|
||||
expect(assistantCreatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(assistantCreatedAt).toBeLessThanOrEqual(Date.now());
|
||||
expect(
|
||||
current.messages.find((message) => message.role === "assistant")
|
||||
?.createdAt,
|
||||
).toBe(thinkingTimestamp);
|
||||
});
|
||||
|
||||
it("updates current token usage from live usage events", async () => {
|
||||
@@ -1714,193 +1537,6 @@ 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;
|
||||
@@ -3732,102 +3368,4 @@ 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,7 +53,6 @@ import type {
|
||||
SessionHistoryItem,
|
||||
SessionHistoryStatus,
|
||||
} from "@/lib/session-history";
|
||||
import { readImportedHistorySummaryActivity } from "@/lib/session-import";
|
||||
import {
|
||||
normalizeWorkspacePath,
|
||||
readWorkspaceSelectionFromWindow,
|
||||
@@ -159,17 +158,8 @@ function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 chunkCreatedAt(payload: AgentChunkEvent): number {
|
||||
return payload.ts || Date.now();
|
||||
}
|
||||
|
||||
function mergeHydratedMessagesWithLive(options: {
|
||||
@@ -388,9 +378,6 @@ 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);
|
||||
@@ -421,7 +408,6 @@ 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,
|
||||
@@ -532,11 +518,9 @@ 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(() => {
|
||||
@@ -832,21 +816,6 @@ 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;
|
||||
@@ -1287,7 +1256,7 @@ export function useChatSession() {
|
||||
sessionId: listeningSessionId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: chunkCreatedAt(),
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
});
|
||||
activeAssistantMessageIdRef.current = assistantId;
|
||||
setActiveAssistantMessageId(assistantId);
|
||||
@@ -1311,7 +1280,7 @@ export function useChatSession() {
|
||||
sessionId: listeningSessionId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: chunkCreatedAt(),
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
});
|
||||
activeAssistantMessageIdRef.current = assistantId;
|
||||
setActiveAssistantMessageId(assistantId);
|
||||
@@ -1414,7 +1383,7 @@ export function useChatSession() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
media: [media.data],
|
||||
createdAt: chunkCreatedAt(),
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1426,7 +1395,6 @@ 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];
|
||||
@@ -1546,7 +1514,7 @@ export function useChatSession() {
|
||||
role: "user",
|
||||
content: userLabel,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
createdAt: chunkCreatedAt(),
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1569,18 +1537,6 @@ 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.
|
||||
}
|
||||
@@ -1617,7 +1573,6 @@ 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
|
||||
@@ -1705,7 +1660,7 @@ export function useChatSession() {
|
||||
input: parsed.input,
|
||||
output: null,
|
||||
}),
|
||||
createdAt: chunkCreatedAt(),
|
||||
createdAt: chunkCreatedAt(payload),
|
||||
meta: {
|
||||
toolName,
|
||||
toolCallId,
|
||||
@@ -1831,20 +1786,6 @@ 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);
|
||||
},
|
||||
@@ -2087,13 +2028,10 @@ export function useChatSession() {
|
||||
],
|
||||
);
|
||||
|
||||
// Resolves to false when the runtime never took the prompt (a failure
|
||||
// before dispatch, or a provider switch / OAuth refresh that threw before
|
||||
// the turn began) so the caller can hand the text back to the composer.
|
||||
const sendPrompt = useCallback(
|
||||
async (prompt: string, attachedFiles: File[] = []): Promise<boolean> => {
|
||||
async (prompt: string, attachedFiles: File[] = []) => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed && attachedFiles.length === 0) return true;
|
||||
if (!trimmed && attachedFiles.length === 0) return;
|
||||
|
||||
setError(null);
|
||||
setIsHydratingSession(false);
|
||||
@@ -2105,7 +2043,7 @@ export function useChatSession() {
|
||||
const validation = validateConfig(config);
|
||||
if (!validation.parsed) {
|
||||
setErrorState(validation.error, activeSessionId);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const parsed = validation.parsed;
|
||||
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
|
||||
@@ -2166,20 +2104,6 @@ export function useChatSession() {
|
||||
: null;
|
||||
const optimisticUserMessageId = shouldQueue ? null : makeId("user");
|
||||
const plannedSessionId = activeSessionId ?? makeId("session");
|
||||
// The prompt never reached the runtime: retract its optimistic bubble
|
||||
// so the caller can hand the text back to the composer without the
|
||||
// transcript showing it as sent.
|
||||
const withdrawPrompt = () => {
|
||||
if (optimisticUserMessageId) {
|
||||
outstandingOptimisticUserIdsRef.current.delete(
|
||||
optimisticUserMessageId,
|
||||
);
|
||||
setMessages((prev) =>
|
||||
prev.filter((message) => message.id !== optimisticUserMessageId),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (optimisticUserMessageId) {
|
||||
outstandingOptimisticUserIdsRef.current.add(optimisticUserMessageId);
|
||||
@@ -2233,7 +2157,7 @@ export function useChatSession() {
|
||||
}
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return withdrawPrompt();
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
activeSessionId &&
|
||||
@@ -2252,7 +2176,7 @@ export function useChatSession() {
|
||||
} catch (err) {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
finishPromptSubmission();
|
||||
return withdrawPrompt();
|
||||
return;
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
@@ -2277,7 +2201,7 @@ export function useChatSession() {
|
||||
}
|
||||
setErrorState(errorMessage(err));
|
||||
finishPromptSubmission();
|
||||
return withdrawPrompt();
|
||||
return;
|
||||
} finally {
|
||||
if (sessionStartPromiseRef.current === startPromise) {
|
||||
sessionStartPromiseRef.current = null;
|
||||
@@ -2291,7 +2215,7 @@ export function useChatSession() {
|
||||
activeSessionId,
|
||||
);
|
||||
finishPromptSubmission();
|
||||
return withdrawPrompt();
|
||||
return;
|
||||
}
|
||||
const serializedAttachments = serializedAttachmentsResult.attachments;
|
||||
const hasAttachments =
|
||||
@@ -2334,10 +2258,9 @@ export function useChatSession() {
|
||||
}
|
||||
if (!sendTask) {
|
||||
finishPromptSubmission();
|
||||
return withdrawPrompt();
|
||||
return;
|
||||
}
|
||||
let abortedReconcileEpoch: number | undefined;
|
||||
let promptTaken = true;
|
||||
const settleAbortedSend = () => {
|
||||
if (!abortedRef.current) return false;
|
||||
if (
|
||||
@@ -2353,7 +2276,7 @@ export function useChatSession() {
|
||||
try {
|
||||
const payload = await sendTask;
|
||||
if (payload.ok && payload.queued) {
|
||||
if (settleAbortedSend()) return true;
|
||||
if (settleAbortedSend()) return;
|
||||
if (turnEpochRef.current !== turnEpochAtDispatch) {
|
||||
// The runtime already started consuming a queued prompt
|
||||
// (chat_queued_prompt_start bumped the epoch) while this
|
||||
@@ -2364,27 +2287,16 @@ export function useChatSession() {
|
||||
// back to "running" — wedging the composer forever. The
|
||||
// stream (chat_queued_prompt_start/chat_done and
|
||||
// prompts_in_queue_state) is authoritative from here on.
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
applyPromptsInQueue(payload.promptsInQueue);
|
||||
setStatus("running");
|
||||
return true;
|
||||
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 true;
|
||||
if (settleAbortedSend()) return;
|
||||
// On a failed run the runtime reports the error string in
|
||||
// result.text — it is not assistant content and must not be
|
||||
// rendered as an assistant bubble (canonical rehydration would
|
||||
@@ -2396,7 +2308,7 @@ export function useChatSession() {
|
||||
);
|
||||
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
|
||||
const resolvedAssistantText = rawAssistantText;
|
||||
if (resolvedAssistantText && !newerTurnOwnsTranscript()) {
|
||||
if (resolvedAssistantText) {
|
||||
const assistantMessageId =
|
||||
activeAssistantMessageIdRef.current ?? makeId("assistant");
|
||||
activeAssistantMessageIdRef.current = assistantMessageId;
|
||||
@@ -2509,7 +2421,7 @@ export function useChatSession() {
|
||||
});
|
||||
}
|
||||
const fallbackMedia = fallbackAssistantTurn.media;
|
||||
if (fallbackMedia.length > 0 && !newerTurnOwnsTranscript()) {
|
||||
if (fallbackMedia.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const knownIds = new Set(
|
||||
prev.flatMap((message) =>
|
||||
@@ -2540,25 +2452,21 @@ export function useChatSession() {
|
||||
fallbackAssistantTurn.reasoningRedacted ||
|
||||
fallbackImages.length > 0 ||
|
||||
fallbackMedia.length > 0;
|
||||
if (!hasFallbackAssistantTurn && !newerTurnOwnsTranscript()) {
|
||||
if (!hasFallbackAssistantTurn) {
|
||||
// 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 && !newerTurnOwnsTranscript()) {
|
||||
if (historyMessages.length > 0) {
|
||||
applyCanonicalHistory(activeSessionId, historyMessages);
|
||||
}
|
||||
} catch {
|
||||
// Keep optimistic state if hydration read fails.
|
||||
}
|
||||
}
|
||||
if (
|
||||
Array.isArray(result?.toolCalls) &&
|
||||
result.toolCalls.length > 0 &&
|
||||
!newerTurnOwnsTranscript()
|
||||
) {
|
||||
if (Array.isArray(result?.toolCalls) && result.toolCalls.length > 0) {
|
||||
materializeToolMessagesFromResult({
|
||||
sessionId: activeSessionId,
|
||||
turnStartedAt: now,
|
||||
@@ -2576,7 +2484,6 @@ export function useChatSession() {
|
||||
);
|
||||
if (
|
||||
historyMessages.length > 0 &&
|
||||
!newerTurnOwnsTranscript() &&
|
||||
(!hasFallbackAssistantTurn || hasCanonicalAssistantTurn)
|
||||
) {
|
||||
const assistantIndex = historyMessages.findLastIndex(
|
||||
@@ -2686,15 +2593,7 @@ export function useChatSession() {
|
||||
const hasQueuedFollowUps =
|
||||
Array.isArray(payload.promptsInQueue) &&
|
||||
payload.promptsInQueue.length > 0;
|
||||
if (settleAbortedSend()) return true;
|
||||
// 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 (settleAbortedSend()) return;
|
||||
if (result?.finishReason === "error") {
|
||||
// On a failed run result.text is the runtime's error string
|
||||
// (never assistant content — see isErrorResult above), so it
|
||||
@@ -2708,25 +2607,11 @@ export function useChatSession() {
|
||||
activeSessionId,
|
||||
runError || toolError?.trim() || "",
|
||||
);
|
||||
if (!newerTurnOwnsStatus) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("failed");
|
||||
}
|
||||
// A run that fails mid-turn always carries `messages` (the
|
||||
// user turn is persisted). The sidecar synthesizes a
|
||||
// messages-less error result when the runtime threw before
|
||||
// the turn began — e.g. the provider switch or OAuth refresh
|
||||
// failed — so the prompt never entered the session.
|
||||
if (!result.messages) {
|
||||
promptTaken = withdrawPrompt();
|
||||
}
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("failed");
|
||||
} else if (result?.finishReason === "aborted") {
|
||||
if (!newerTurnOwnsStatus) {
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
}
|
||||
} else if (newerTurnOwnsStatus) {
|
||||
// Leave status to the turn in flight.
|
||||
turnSettledEpochRef.current = turnEpochRef.current;
|
||||
setStatus("cancelled");
|
||||
} else if (hasQueuedFollowUps) {
|
||||
setStatus("running");
|
||||
} else {
|
||||
@@ -2735,7 +2620,7 @@ export function useChatSession() {
|
||||
}
|
||||
void refreshSessionDiffSummary(activeSessionId);
|
||||
} catch (err) {
|
||||
if (settleAbortedSend()) return true;
|
||||
if (settleAbortedSend()) return;
|
||||
if (optimisticQueuedPromptId) {
|
||||
setPromptsInQueue((prev) =>
|
||||
prev.filter((item) => item.id !== optimisticQueuedPromptId),
|
||||
@@ -2744,10 +2629,7 @@ export function useChatSession() {
|
||||
setErrorState(errorMessage(err), activeSessionId);
|
||||
} finally {
|
||||
clearAbortFallbackTimeout();
|
||||
// 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) {
|
||||
if (!shouldQueue) {
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
clearLiveToolRefs();
|
||||
@@ -2761,7 +2643,6 @@ export function useChatSession() {
|
||||
finalizeSettledTurn(activeSessionId);
|
||||
}
|
||||
}
|
||||
return promptTaken;
|
||||
},
|
||||
[
|
||||
addMessage,
|
||||
@@ -2981,7 +2862,6 @@ export function useChatSession() {
|
||||
lastCoreErrorBySessionRef.current = {};
|
||||
activeAssistantMessageIdRef.current = null;
|
||||
setActiveAssistantMessageId(null);
|
||||
setActivityLabel(null);
|
||||
setHydratedHistorySessionId(null);
|
||||
setPendingToolApprovals([]);
|
||||
setPendingAskQuestions([]);
|
||||
@@ -3026,7 +2906,6 @@ 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.
|
||||
@@ -3299,7 +3178,6 @@ export function useChatSession() {
|
||||
chatTransportError,
|
||||
isHydratingSession,
|
||||
activeAssistantMessageId,
|
||||
activityLabel,
|
||||
config,
|
||||
messages,
|
||||
rawTranscript,
|
||||
|
||||