mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
Merge remote-tracking branch 'origin/main' into bee/keybindings
# Conflicts: # apps/examples/desktop-app/package.json # apps/examples/desktop-app/sidecar/commands.ts # apps/examples/desktop-app/webview/app/page.tsx
This commit is contained in:
@@ -25,7 +25,8 @@ The skill should guide the user through one release preparation flow, then offer
|
||||
- Local publish helper: `bun release cli`.
|
||||
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
|
||||
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
|
||||
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
|
||||
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag. It is manual dispatch only; there is no cron.
|
||||
- Every CI publish (CLI main and nightly, SDK, UI) waits for a reviewer to approve the run in the `PublishNPM` GitHub environment, and the npm trusted publishers require that environment. The run sits in `waiting` until approved; that is not a hang. Hand the user the run URL so they can click "Review deployments"; never approve it via the API.
|
||||
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
|
||||
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
@@ -22,8 +22,8 @@ Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- The publish job and npm trust relationship use the protected `PublishNPM`
|
||||
environment; the run waits for a reviewer to approve it before publishing.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
/.github/ @saoudrizwan
|
||||
/README.md @saoudrizwan
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: cli-publish
|
||||
|
||||
# Manual dispatch only, nightly included. Every publish waits for approval on
|
||||
# the PublishNPM environment, so a cron run would just sit waiting every day.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_target:
|
||||
@@ -48,6 +48,11 @@ jobs:
|
||||
github.event.inputs.publish_target == 'main' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
# npm trusted publishing for every CLI package requires this environment,
|
||||
# so a publish only happens after a PublishNPM reviewer approves the run.
|
||||
# The environment's deployment-branch policy (main only) is the enforced
|
||||
# branch gate; the `if` above is advisory.
|
||||
environment: PublishNPM
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -305,13 +310,13 @@ jobs:
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.publish_target == 'nightly'
|
||||
)
|
||||
)
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.publish_target == 'nightly'
|
||||
# npm trusted publishing for every CLI package requires this environment,
|
||||
# so a publish only happens after a PublishNPM reviewer approves the run.
|
||||
# The environment's deployment-branch policy (main only) is the enforced
|
||||
# branch gate; the `if` above is advisory.
|
||||
environment: PublishNPM
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -323,7 +328,7 @@ jobs:
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
env:
|
||||
FORCE_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
|
||||
FORCE_PUBLISH: ${{ github.event.inputs.force_nightly_publish == 'true' }}
|
||||
run: |
|
||||
if [ "$FORCE_PUBLISH" = "true" ]; then
|
||||
echo "force_nightly_publish enabled, proceeding with publish"
|
||||
|
||||
@@ -86,12 +86,12 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$zip = Join-Path $env:RUNNER_TEMP 'nsis.zip'
|
||||
Invoke-WebRequest 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip' -OutFile $zip
|
||||
if ((Get-FileHash $zip -Algorithm SHA1).Hash -ne 'EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D') {
|
||||
Invoke-WebRequest 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.13/nsis-3.13.zip' -OutFile $zip
|
||||
if ((Get-FileHash $zip -Algorithm SHA1).Hash -ne 'DB14F8AF2EA346B786C6AE19343F61AAF5E09B8E') {
|
||||
throw 'NSIS distribution checksum mismatch'
|
||||
}
|
||||
Expand-Archive $zip -DestinationPath $env:RUNNER_TEMP
|
||||
"MAKENSIS_PATH=$env:RUNNER_TEMP/nsis-3.11/makensis.exe" >> $env:GITHUB_ENV
|
||||
"MAKENSIS_PATH=$env:RUNNER_TEMP/nsis-3.13/makensis.exe" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Test install and uninstall process cleanup
|
||||
run: bun run test:windows-installer
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
name: sdk-publish
|
||||
|
||||
# Manual dispatch only. Every publish (nightly included) waits for approval on
|
||||
# the PublishNPM environment, so a cron run would just sit waiting every day.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -20,9 +22,6 @@ on:
|
||||
description: 'Required when channel=latest. Type "publish" to confirm release publish.'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Run nightly at 2:00 AM UTC
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -51,6 +50,9 @@ jobs:
|
||||
!endsWith(github.actor, '[bot]')
|
||||
)
|
||||
)
|
||||
# npm trusted publishing for the SDK packages requires this environment,
|
||||
# so a publish only happens after a PublishNPM reviewer approves the run.
|
||||
environment: PublishNPM
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -60,15 +62,8 @@ jobs:
|
||||
- name: Determine publish channel
|
||||
id: channel
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
# Default to nightly for scheduled runs
|
||||
if [ "$EVENT_NAME" = "schedule" ]; then
|
||||
echo "channel=nightly" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
run: echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
|
||||
@@ -105,6 +105,9 @@ jobs:
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
# npm trusted publishing for @cline/ui requires this environment, so a
|
||||
# publish only happens after a PublishNPM reviewer approves the run.
|
||||
environment: PublishNPM
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 align="center">Cline</h1>
|
||||
|
||||
<p align="center">
|
||||
The open source coding agent in your IDE, terminal, & desktop.
|
||||
The open source coding agent in your IDE, terminal, & desktop!
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -129,10 +129,11 @@ The GitHub workflow at `.github/workflows/cli-publish.yml` automates publishing:
|
||||
|
||||
- Main releases are manual. Select `publish_target=main` and set `confirm_publish=publish`.
|
||||
- Main releases require `git_tag=cli-vX.Y.Z`, check out that tag, verify it matches `apps/cli/package.json`, run tests, build all platform packages, publish to npm with the `latest` dist-tag using trusted publishing, create a GitHub release, and post to Slack.
|
||||
- Nightly releases run on a schedule or manually with `publish_target=nightly`.
|
||||
- Nightly releases are manual only, with `publish_target=nightly`. There is no schedule.
|
||||
- Every run, main or nightly, waits for a reviewer to approve it in the `PublishNPM` GitHub environment.
|
||||
- Nightly releases publish `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skip if there were no commits in the last 24 hours unless forced.
|
||||
|
||||
CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package before relying on the workflow.
|
||||
CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package, with the environment set to `PublishNPM`, before relying on the workflow.
|
||||
|
||||
## How It Works Under the Hood
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
@@ -47,11 +48,8 @@ import {
|
||||
authenticateAcpProvider,
|
||||
isAcpAuthMethodId,
|
||||
} from "./auth";
|
||||
import {
|
||||
AUTO_APPROVE_CONFIG_ID,
|
||||
buildAutoApproveConfigOption,
|
||||
parseAutoApproveValue,
|
||||
} from "./auto-approve";
|
||||
import { AUTO_APPROVE_CONFIG_ID, parseAutoApproveValue } from "./auto-approve";
|
||||
import { buildConfigOptions } from "./config-options";
|
||||
import {
|
||||
buildOrganizationConfigOption,
|
||||
fetchClineOrganizations,
|
||||
@@ -236,13 +234,15 @@ export class AcpAgent implements Agent {
|
||||
availableModels,
|
||||
currentModelId: defaultModelId,
|
||||
},
|
||||
configOptions: [
|
||||
await buildProviderConfigOption(providerId),
|
||||
buildModelConfigOption(defaultModelId, providerModels),
|
||||
buildModeConfigOption(defaultMode),
|
||||
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
|
||||
...(organizationOption ? [organizationOption] : []),
|
||||
],
|
||||
configOptions: buildConfigOptions({
|
||||
currentProviderId: providerId,
|
||||
providerOptions: await getProviderOptions(),
|
||||
currentModelId: defaultModelId,
|
||||
providerModels,
|
||||
currentMode: defaultMode,
|
||||
autoApproveTools: this.defaultAutoApproveTools,
|
||||
organizationOption,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,13 +562,13 @@ export class AcpAgent implements Agent {
|
||||
);
|
||||
}
|
||||
|
||||
const configOptions = await buildAllConfigOptions(session);
|
||||
const organizationOption = await this.getOrganizationConfigOption(
|
||||
session.currentProviderId,
|
||||
);
|
||||
if (organizationOption) {
|
||||
configOptions.push(organizationOption);
|
||||
}
|
||||
const configOptions = await buildAllConfigOptions(
|
||||
session,
|
||||
organizationOption,
|
||||
);
|
||||
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
|
||||
return { configOptions };
|
||||
}
|
||||
@@ -857,10 +857,8 @@ function toAcpPromptError(error: Error): RequestError {
|
||||
: RequestError.internalError({ message }, message);
|
||||
}
|
||||
|
||||
async function buildProviderConfigOption(
|
||||
currentProviderId: string,
|
||||
): Promise<SessionConfigOption> {
|
||||
const options = await Promise.all(
|
||||
async function getProviderOptions(): Promise<SessionConfigSelectOption[]> {
|
||||
return Promise.all(
|
||||
ACP_AUTH_METHODS.map(async (m) => {
|
||||
const provider = await Llms.getProvider(m.id);
|
||||
return {
|
||||
@@ -869,75 +867,28 @@ async function buildProviderConfigOption(
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
type: "select",
|
||||
id: "provider",
|
||||
name: "Provider",
|
||||
description: "The authentication provider to use",
|
||||
category: "model",
|
||||
currentValue: currentProviderId,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModelConfigOption(
|
||||
currentModelId: string,
|
||||
providerModels: Record<string, { name?: string; description?: string }>,
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
currentValue: currentModelId,
|
||||
options: Object.entries(providerModels).map(([modelId, info]) => ({
|
||||
value: modelId,
|
||||
name: info.name ?? modelId,
|
||||
description: info.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildModeConfigOption(currentMode: string): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
description: "Controls whether the agent can modify files",
|
||||
category: "mode",
|
||||
currentValue: currentMode,
|
||||
options: [
|
||||
{
|
||||
value: "plan",
|
||||
name: "Plan",
|
||||
description:
|
||||
"Explore the codebase and plan changes without modifying files",
|
||||
},
|
||||
{
|
||||
value: "act",
|
||||
name: "Act",
|
||||
description: "Make changes to the codebase",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function buildAllConfigOptions(
|
||||
session: SessionState,
|
||||
organizationOption?: SessionConfigOption,
|
||||
): Promise<SessionConfigOption[]> {
|
||||
const [providerOption, providerModels] = await Promise.all([
|
||||
buildProviderConfigOption(session.currentProviderId),
|
||||
const [providerOptions, providerModels] = await Promise.all([
|
||||
getProviderOptions(),
|
||||
Llms.getModelsForProvider(
|
||||
session.currentProviderId,
|
||||
CHAT_MODEL_QUERY_OPTIONS,
|
||||
),
|
||||
]);
|
||||
return [
|
||||
providerOption,
|
||||
buildModelConfigOption(session.currentModelId, providerModels),
|
||||
buildModeConfigOption(session.currentMode),
|
||||
buildAutoApproveConfigOption(session.autoApproveTools),
|
||||
];
|
||||
return buildConfigOptions({
|
||||
currentProviderId: session.currentProviderId,
|
||||
providerOptions,
|
||||
currentModelId: session.currentModelId,
|
||||
providerModels,
|
||||
currentMode: session.currentMode,
|
||||
autoApproveTools: session.autoApproveTools,
|
||||
organizationOption,
|
||||
});
|
||||
}
|
||||
|
||||
function extractTextFromContentBlocks(blocks: ContentBlock[]): string {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildConfigOptions,
|
||||
buildProviderConfigOption,
|
||||
MODEL_CONFIG_CATEGORY,
|
||||
PROVIDER_CONFIG_CATEGORY,
|
||||
} from "./config-options";
|
||||
|
||||
// ACP reserves every category name that does not start with `_` for the spec.
|
||||
const ACP_RESERVED_CATEGORIES = [
|
||||
"mode",
|
||||
"model",
|
||||
"model_config",
|
||||
"thought_level",
|
||||
];
|
||||
|
||||
const providerModels = {
|
||||
"anthropic/claude-sonnet-5.5": { name: "Claude Sonnet 5.5" },
|
||||
"openrouter/free": { name: "Free" },
|
||||
};
|
||||
|
||||
const providerOptions = [
|
||||
{ value: "cline", name: "Cline" },
|
||||
{ value: "openrouter", name: "OpenRouter" },
|
||||
];
|
||||
|
||||
const organizationOption: SessionConfigOption = {
|
||||
type: "select",
|
||||
id: "organization",
|
||||
name: "Account",
|
||||
category: "_account",
|
||||
currentValue: "personal",
|
||||
options: [{ value: "personal", name: "Personal" }],
|
||||
};
|
||||
|
||||
describe("buildProviderConfigOption", () => {
|
||||
it("builds the provider selector from the current provider", () => {
|
||||
const option = buildProviderConfigOption("cline", providerOptions);
|
||||
|
||||
expect(option).toMatchObject({
|
||||
type: "select",
|
||||
id: "provider",
|
||||
currentValue: "cline",
|
||||
options: providerOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a custom category instead of a spec-reserved one", () => {
|
||||
const option = buildProviderConfigOption("cline", providerOptions);
|
||||
|
||||
expect(option.category).toBe(PROVIDER_CONFIG_CATEGORY);
|
||||
expect(ACP_RESERVED_CATEGORIES).not.toContain(option.category);
|
||||
expect(option.category).toMatch(/^_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
false,
|
||||
true,
|
||||
])("ACP config options (organization: %s)", (includeOrganization) => {
|
||||
const options = buildConfigOptions({
|
||||
currentProviderId: "cline",
|
||||
providerOptions,
|
||||
currentModelId: "anthropic/claude-sonnet-5.5",
|
||||
providerModels,
|
||||
currentMode: "act",
|
||||
autoApproveTools: false,
|
||||
organizationOption: includeOrganization ? organizationOption : undefined,
|
||||
});
|
||||
|
||||
it("tags only the model selector with the model category", () => {
|
||||
expect(
|
||||
options
|
||||
.filter((option) => option.category === MODEL_CONFIG_CATEGORY)
|
||||
.map((option) => option.id),
|
||||
).toEqual(["model"]);
|
||||
});
|
||||
|
||||
it("uses reserved or custom category names for every option", () => {
|
||||
for (const { category } of options) {
|
||||
expect(
|
||||
category === undefined ||
|
||||
category === null ||
|
||||
ACP_RESERVED_CATEGORIES.includes(category) ||
|
||||
category.startsWith("_"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not repeat reserved categories in the assembled list", () => {
|
||||
const categories = options
|
||||
.map((option) => option.category)
|
||||
.filter(
|
||||
(category) => category && ACP_RESERVED_CATEGORIES.includes(category),
|
||||
);
|
||||
|
||||
expect(new Set(categories).size).toBe(categories.length);
|
||||
});
|
||||
|
||||
it("preserves the option order", () => {
|
||||
expect(options.map((option) => option.id)).toEqual([
|
||||
"provider",
|
||||
"model",
|
||||
"mode",
|
||||
"auto_approve",
|
||||
...(includeOrganization ? ["organization"] : []),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { buildAutoApproveConfigOption } from "./auto-approve";
|
||||
|
||||
// Clients break category ties by array order, so the provider must not claim
|
||||
// the model selector's category when it comes first.
|
||||
export const PROVIDER_CONFIG_CATEGORY = "_provider";
|
||||
export const MODEL_CONFIG_CATEGORY = "model";
|
||||
|
||||
interface ConfigOptionsInput {
|
||||
currentProviderId: string;
|
||||
providerOptions: SessionConfigSelectOption[];
|
||||
currentModelId: string;
|
||||
providerModels: Record<string, { name?: string; description?: string }>;
|
||||
currentMode: string;
|
||||
autoApproveTools: boolean;
|
||||
organizationOption?: SessionConfigOption;
|
||||
}
|
||||
|
||||
export function buildProviderConfigOption(
|
||||
currentProviderId: string,
|
||||
options: SessionConfigSelectOption[],
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: "provider",
|
||||
name: "Provider",
|
||||
description: "The authentication provider to use",
|
||||
category: PROVIDER_CONFIG_CATEGORY,
|
||||
currentValue: currentProviderId,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelConfigOption(
|
||||
currentModelId: string,
|
||||
providerModels: Record<string, { name?: string; description?: string }>,
|
||||
): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: MODEL_CONFIG_CATEGORY,
|
||||
currentValue: currentModelId,
|
||||
options: Object.entries(providerModels).map(([modelId, info]) => ({
|
||||
value: modelId,
|
||||
name: info.name ?? modelId,
|
||||
description: info.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildModeConfigOption(currentMode: string): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
description: "Controls whether the agent can modify files",
|
||||
category: "mode",
|
||||
currentValue: currentMode,
|
||||
options: [
|
||||
{
|
||||
value: "plan",
|
||||
name: "Plan",
|
||||
description:
|
||||
"Explore the codebase and plan changes without modifying files",
|
||||
},
|
||||
{
|
||||
value: "act",
|
||||
name: "Act",
|
||||
description: "Make changes to the codebase",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfigOptions({
|
||||
currentProviderId,
|
||||
providerOptions,
|
||||
currentModelId,
|
||||
providerModels,
|
||||
currentMode,
|
||||
autoApproveTools,
|
||||
organizationOption,
|
||||
}: ConfigOptionsInput): SessionConfigOption[] {
|
||||
return [
|
||||
buildProviderConfigOption(currentProviderId, providerOptions),
|
||||
buildModelConfigOption(currentModelId, providerModels),
|
||||
buildModeConfigOption(currentMode),
|
||||
buildAutoApproveConfigOption(autoApproveTools),
|
||||
...(organizationOption ? [organizationOption] : []),
|
||||
];
|
||||
}
|
||||
@@ -28,6 +28,7 @@ describe("buildOrganizationConfigOption", () => {
|
||||
activeOrganizationId: "org-2",
|
||||
});
|
||||
expect(option.id).toBe("organization");
|
||||
expect(option.category).toBe("_account");
|
||||
if (option.type !== "select") {
|
||||
throw new Error(`expected a select option, got ${option.type}`);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function buildOrganizationConfigOption(
|
||||
name: "Account",
|
||||
description:
|
||||
"The Cline account usage is billed to — your personal account or an organization",
|
||||
category: "account",
|
||||
category: "_account",
|
||||
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
|
||||
options: [
|
||||
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
|
||||
"pretest:settings-ui": "bun run build:ui",
|
||||
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx webview/components/quit-hold-handler.test.tsx webview/lib/keybindings.test.ts --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
|
||||
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
@@ -110,7 +110,7 @@
|
||||
"devDependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.12.3",
|
||||
"@types/bun": "^1.4.2",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
@@ -145,6 +145,29 @@ function processCommandLine(pid: number): string | undefined {
|
||||
return ps.status === 0 ? ps.stdout.trim() : undefined;
|
||||
}
|
||||
|
||||
function waitForHubStartup(wsEndpoint: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsEndpoint);
|
||||
const timer = setTimeout(() => {
|
||||
socket.close();
|
||||
reject(new Error("Hub startup did not settle within 30 seconds"));
|
||||
}, 30_000);
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
const state = message.event?.payload?.state;
|
||||
if (message.event?.name === "hub_status" && state !== "starting") {
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
resolve(state);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Could not connect to ${wsEndpoint}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function runStartupScenario(
|
||||
extraEnv: Record<string, string>,
|
||||
options: { hubAlreadyRunning: boolean; discoveryContents?: string },
|
||||
@@ -231,12 +254,16 @@ async function runStartupScenario(
|
||||
},
|
||||
);
|
||||
let endpoint: string | undefined;
|
||||
let wsEndpoint: string | undefined;
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.type === "ready") endpoint = message.endpoint;
|
||||
if (message.type === "ready") {
|
||||
endpoint = message.endpoint;
|
||||
wsEndpoint = message.wsEndpoint;
|
||||
}
|
||||
} catch {
|
||||
/* Ignore other output and incomplete lines. */
|
||||
}
|
||||
@@ -253,6 +280,11 @@ async function runStartupScenario(
|
||||
});
|
||||
expect(health.ok).toBe(true);
|
||||
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
|
||||
// The backend publishes its endpoint before starting the Hub and reports
|
||||
// Hub startup over the transport, replaying the current state on connect.
|
||||
expect(await waitForHubStartup(String(wsEndpoint)), stderrPath).toBe(
|
||||
"ready",
|
||||
);
|
||||
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
|
||||
hubPid = hub.pid;
|
||||
if (existingHub) {
|
||||
|
||||
@@ -28,8 +28,8 @@ const lockfile = path.resolve(import.meta.dir, "../../../../bun.lock");
|
||||
// of the locked @tauri-apps/cli), and desktop-test.yml downloads that same
|
||||
// archive by hand. Pin both ends here so a Tauri CLI bump, or an edit to the
|
||||
// workflow's download, fails this suite until the pair is re-checked together.
|
||||
const TAURI_CLI_VERSION = "2.11.4";
|
||||
const NSIS_VERSION = "v3.11";
|
||||
const TAURI_CLI_VERSION = "2.12.3";
|
||||
const NSIS_VERSION = "v3.13";
|
||||
const children: ChildProcess[] = [];
|
||||
let root: string;
|
||||
let fixture: string;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleCommand } from "./commands";
|
||||
import { HubStartup } from "./hub-startup";
|
||||
import type { SidecarContext, SidecarWebSocketClient } from "./types";
|
||||
|
||||
function contextWith(hubStartup: HubStartup): SidecarContext {
|
||||
return {
|
||||
localWorkspaceRoot: "/workspace",
|
||||
wsClients: new Set(),
|
||||
runtimeBindings: new Map(),
|
||||
hubBuildMismatch: null,
|
||||
hubStartup,
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
}
|
||||
|
||||
describe("commands during Hub startup", () => {
|
||||
it("answers retry without waiting for the Hub", async () => {
|
||||
const startup = new HubStartup(
|
||||
() => new Promise(() => {}),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
);
|
||||
startup.start();
|
||||
const ctx = contextWith(startup);
|
||||
await expect(handleCommand(ctx, "retry_hub")).resolves.toEqual({
|
||||
state: "starting",
|
||||
});
|
||||
});
|
||||
|
||||
it("holds Hub-backed commands until startup settles", async () => {
|
||||
let finishSetup!: () => void;
|
||||
const startup = new HubStartup(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishSetup = resolve;
|
||||
}),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
);
|
||||
startup.start();
|
||||
let settled = false;
|
||||
const pending = handleCommand(
|
||||
contextWith(startup),
|
||||
"no_such_command",
|
||||
).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(settled).toBe(false);
|
||||
finishSetup();
|
||||
await expect(pending).rejects.toThrow();
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a waiting command whose connection closed during startup", async () => {
|
||||
let finishSetup!: () => void;
|
||||
const startup = new HubStartup(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishSetup = resolve;
|
||||
}),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
);
|
||||
startup.start();
|
||||
const ctx = contextWith(startup);
|
||||
const connection = {} as SidecarWebSocketClient;
|
||||
ctx.wsClients.add(connection);
|
||||
const pending = handleCommand(
|
||||
ctx,
|
||||
"run_provider_oauth_login",
|
||||
{ providerId: "cline" },
|
||||
{ connection },
|
||||
);
|
||||
ctx.wsClients.delete(connection);
|
||||
finishSetup();
|
||||
await expect(pending).rejects.toThrow(
|
||||
"The connection closed while Cline Hub was starting.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
refreshDesktopFeatureFlags,
|
||||
} from "./feature-flags";
|
||||
import { getHubStatus, restartHub } from "./hub-admin";
|
||||
import { isHubIndependentCommand } from "./hub-startup";
|
||||
import {
|
||||
readKeybindings,
|
||||
resolveKeybindingsPath,
|
||||
@@ -1882,6 +1883,20 @@ export async function handleCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options?: { connection?: SidecarWebSocketClient },
|
||||
): Promise<unknown> {
|
||||
if (command === "retry_hub") {
|
||||
ctx.hubStartup?.start();
|
||||
return ctx.hubStartup?.status ?? null;
|
||||
}
|
||||
// The transport is published before the Hub is ready. Hold Hub-backed
|
||||
// commands until startup settles, as they waited for the transport before.
|
||||
if (ctx.hubStartup && !isHubIndependentCommand(command)) {
|
||||
await ctx.hubStartup.waitUntilSettled();
|
||||
// Its close cleanup already ran, so nothing could cancel work (such as a
|
||||
// browser sign-in) started for a connection that left while waiting.
|
||||
if (options?.connection && !ctx.wsClients.has(options.connection)) {
|
||||
throw new Error("The connection closed while Cline Hub was starting.");
|
||||
}
|
||||
}
|
||||
const explicitEnvironment = requestedEnvironmentId(args);
|
||||
if (explicitEnvironment) {
|
||||
ctx = getEnvironmentContext(ctx, explicitEnvironment);
|
||||
|
||||
@@ -158,6 +158,23 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the installed session manager when a retried startup only failed its readiness sync", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
updateCapabilitiesMock.mockRejectedValueOnce(new Error("hub busy"));
|
||||
|
||||
await expect(initializeSessionManager(ctx)).rejects.toThrow("hub busy");
|
||||
const installed = ctx.runtimeBindings.get("local");
|
||||
expect(installed).toBeDefined();
|
||||
|
||||
await initializeSessionManager(ctx);
|
||||
expect(createCoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.runtimeBindings.get("local")).toBe(installed);
|
||||
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
|
||||
@@ -1296,6 +1296,12 @@ async function handleHubApprovalRequest(
|
||||
export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
// A retried startup must not replace (and leak) a manager that an earlier
|
||||
// attempt installed before its final readiness sync failed.
|
||||
if (ctx.runtimeBindings.has(LOCAL_ENVIRONMENT_ID)) {
|
||||
await syncSidecarApprovalReadiness(ctx);
|
||||
return;
|
||||
}
|
||||
setHomeDirIfUnset(homedir());
|
||||
const sessionManager = await ClineCore.create({
|
||||
clientName: "cline-code",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HubStartup, type HubStartupStatus } from "./hub-startup";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HubStartup", () => {
|
||||
it("reports ready when setup succeeds and releases waiting commands", async () => {
|
||||
const setup = deferred();
|
||||
const statuses: HubStartupStatus[] = [];
|
||||
const startup = new HubStartup(
|
||||
() => setup.promise,
|
||||
(status) => statuses.push(status),
|
||||
vi.fn(),
|
||||
);
|
||||
startup.start();
|
||||
const waiting = startup.waitUntilSettled();
|
||||
setup.resolve();
|
||||
await waiting;
|
||||
expect(statuses.map((status) => status.state)).toEqual([
|
||||
"starting",
|
||||
"ready",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a fixed message and hands the raw error to the failure hook", async () => {
|
||||
const error = new Error(
|
||||
"hub exploded at ws://127.0.0.1:25463?token=secret",
|
||||
);
|
||||
const onFailure = vi.fn();
|
||||
const startup = new HubStartup(
|
||||
() => Promise.reject(error),
|
||||
vi.fn(),
|
||||
onFailure,
|
||||
);
|
||||
startup.start();
|
||||
await startup.waitUntilSettled();
|
||||
expect(onFailure).toHaveBeenCalledWith(error);
|
||||
expect(startup.status.state).toBe("failed");
|
||||
expect(startup.status.error).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("fails after the timeout but still becomes ready if the slow setup finishes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setup = deferred();
|
||||
const onFailure = vi.fn();
|
||||
const startup = new HubStartup(
|
||||
() => setup.promise,
|
||||
vi.fn(),
|
||||
onFailure,
|
||||
1_000,
|
||||
);
|
||||
startup.start();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(startup.status).toEqual({
|
||||
state: "failed",
|
||||
error: "Cline Hub did not respond within 30 seconds.",
|
||||
});
|
||||
expect(onFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("1000ms") }),
|
||||
);
|
||||
setup.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(startup.status.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("reuses a pending attempt on retry and starts a new one after it fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = deferred();
|
||||
const run = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const startup = new HubStartup(run, vi.fn(), vi.fn(), 1_000);
|
||||
startup.start();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
startup.start();
|
||||
expect(startup.status.state).toBe("starting");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.reject(new Error("still broken"));
|
||||
await vi.runAllTimersAsync();
|
||||
expect(startup.status.state).toBe("failed");
|
||||
startup.start();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(startup.status.state).toBe("ready");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { DesktopHubStatus } from "../webview/lib/desktop-transport";
|
||||
|
||||
export type HubStartupStatus = DesktopHubStatus;
|
||||
|
||||
const HUB_STARTUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Commands the startup screen needs before the Hub is ready. */
|
||||
const HUB_INDEPENDENT_COMMANDS = new Set(["retry_hub", "export_diagnostics"]);
|
||||
|
||||
export function isHubIndependentCommand(command: string): boolean {
|
||||
return HUB_INDEPENDENT_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs local Hub setup after the sidecar is already reachable, so the webview
|
||||
* can show progress, a failure, Retry, and diagnostics export instead of
|
||||
* waiting on a backend that never comes up.
|
||||
*
|
||||
* Only one setup attempt runs at a time. A timeout reports failure but lets
|
||||
* the attempt keep going: if a slow Hub answers later, startup still
|
||||
* completes. Retry while an attempt is pending waits on that same attempt.
|
||||
*/
|
||||
export class HubStartup {
|
||||
status: HubStartupStatus = { state: "starting" };
|
||||
private attempt?: Promise<void>;
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private waiters: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
private readonly run: () => Promise<void>,
|
||||
private readonly onChange: (status: HubStartupStatus) => void,
|
||||
private readonly onFailure: (error: unknown) => void,
|
||||
private readonly timeoutMs = HUB_STARTUP_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.status.state === "ready") return;
|
||||
this.setStatus({ state: "starting" });
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
if (this.status.state === "starting") {
|
||||
this.onFailure(
|
||||
new Error(
|
||||
`Local Hub startup did not finish within ${this.timeoutMs}ms; still waiting in the background`,
|
||||
),
|
||||
);
|
||||
this.setStatus({
|
||||
state: "failed",
|
||||
error: "Cline Hub did not respond within 30 seconds.",
|
||||
});
|
||||
}
|
||||
}, this.timeoutMs);
|
||||
if (this.attempt) return;
|
||||
this.attempt = this.run()
|
||||
.then(
|
||||
() => this.setStatus({ state: "ready" }),
|
||||
(error) => {
|
||||
this.onFailure(error);
|
||||
this.setStatus({
|
||||
state: "failed",
|
||||
error: "Cline couldn't start or connect to its Hub.",
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
this.attempt = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once startup is no longer in progress (ready or failed). */
|
||||
waitUntilSettled(): Promise<void> {
|
||||
if (this.status.state !== "starting") return Promise.resolve();
|
||||
return new Promise((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
|
||||
private setStatus(status: HubStartupStatus): void {
|
||||
this.status = status;
|
||||
if (status.state !== "starting") {
|
||||
clearTimeout(this.timer);
|
||||
for (const resolve of this.waiters.splice(0)) resolve();
|
||||
}
|
||||
this.onChange(status);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
disposeSidecarContext,
|
||||
initializeSessionManager,
|
||||
} from "./context";
|
||||
import { HubStartup } from "./hub-startup";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
@@ -90,23 +91,43 @@ async function main(desktopCliPath?: string) {
|
||||
}
|
||||
|
||||
prewarmWorkspaceMetadata(workspaceRoot);
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
await shellPathPromise,
|
||||
);
|
||||
// The packaged app hands Hub startup to its bundled CLI: it reuses a
|
||||
// compatible healthy Hub or starts one, and the session manager attaches.
|
||||
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
|
||||
// the session manager still starts the Hub through the same CLI launcher.
|
||||
if (desktopCliPath) {
|
||||
try {
|
||||
const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot);
|
||||
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
|
||||
} catch (error) {
|
||||
observability.logger.error?.("Cline CLI hub ensure failed", { error });
|
||||
|
||||
const setUpLocalHub = async (): Promise<void> => {
|
||||
observability.logger.log(
|
||||
"Login shell PATH resolution",
|
||||
await shellPathPromise,
|
||||
);
|
||||
// The packaged app hands Hub startup to its bundled CLI: it reuses a
|
||||
// compatible healthy Hub or starts one, and the session manager attaches.
|
||||
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
|
||||
// the session manager still starts the Hub through the same CLI launcher.
|
||||
if (desktopCliPath) {
|
||||
try {
|
||||
const hub = await ensureHubWithDesktopCli(
|
||||
desktopCliPath,
|
||||
workspaceRoot,
|
||||
);
|
||||
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
|
||||
} catch (error) {
|
||||
observability.logger.error?.("Cline CLI hub ensure failed", { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
await initializeSessionManager(ctx);
|
||||
await initializeSessionManager(ctx);
|
||||
checkStartupHubBuildMismatch();
|
||||
};
|
||||
ctx.hubStartup = new HubStartup(
|
||||
setUpLocalHub,
|
||||
(status) => broadcastEvent(ctx, "hub_status", status),
|
||||
(error) => {
|
||||
observability.logger.error?.("Local Hub startup failed", { error });
|
||||
captureSdkError(observability.telemetry, {
|
||||
component: "desktop",
|
||||
operation: "sidecar.hub_startup",
|
||||
error,
|
||||
handled: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
let handlingFatalError = false;
|
||||
@@ -184,16 +205,35 @@ async function main(desktopCliPath?: string) {
|
||||
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
|
||||
},
|
||||
});
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
|
||||
wsEndpoint.searchParams.set("approval_token", approvalToken);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
endpoint,
|
||||
wsEndpoint: wsEndpoint.toString(),
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
})}\n`,
|
||||
);
|
||||
// The webview is reachable now, so it can show Hub startup progress and
|
||||
// failures instead of commands timing out against an unpublished backend.
|
||||
ctx.hubStartup.start();
|
||||
|
||||
// The watcher's first check only runs after its interval, but a mismatch
|
||||
// that already exists at startup - an older Hub this app attached to
|
||||
// because it is still serving other clients' sessions - must prompt
|
||||
// before the user starts working, not half a minute in. Session-manager
|
||||
// init has already settled the hub state, so check once right away. The
|
||||
// broadcast reaches webviews that are already connected; the replay in
|
||||
// before the user starts working, not half a minute in. Hub setup has
|
||||
// settled the hub state by the time this runs. The broadcast reaches
|
||||
// webviews that are already connected; the replay in
|
||||
// createWebSocketHandler covers ones that connect later. Skipped when
|
||||
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
|
||||
// hosts keep protocol-only compatibility and must not show update prompts.
|
||||
if (!process.env.CLINE_HUB_PORT?.trim()) {
|
||||
function checkStartupHubBuildMismatch(): void {
|
||||
if (process.env.CLINE_HUB_PORT?.trim()) return;
|
||||
void checkManagedHubBuildMismatch()
|
||||
.then((mismatch) => {
|
||||
if (!mismatch || ctx.hubBuildMismatch) {
|
||||
@@ -212,21 +252,6 @@ async function main(desktopCliPath?: string) {
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
|
||||
wsEndpoint.searchParams.set("approval_token", approvalToken);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
type: "ready",
|
||||
endpoint,
|
||||
wsEndpoint: wsEndpoint.toString(),
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -364,6 +364,9 @@ export function createWebSocketHandler(ctx: SidecarContext) {
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
});
|
||||
if (ctx.hubStartup) {
|
||||
ws.send(encodeSidecarEvent("hub_status", ctx.hubStartup.status));
|
||||
}
|
||||
// Replay a pending mismatch so webviews that connect (or reload)
|
||||
// after detection still prompt the user to update and restart.
|
||||
if (ctx.hubBuildMismatch) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type { UserContext } from "@cline/shared";
|
||||
import type { HubStartup } from "./hub-startup";
|
||||
|
||||
export const LOCAL_ENVIRONMENT_ID = "local";
|
||||
|
||||
@@ -166,6 +167,8 @@ export type SidecarContext = {
|
||||
* replayed to webviews that connect after the event fired.
|
||||
*/
|
||||
hubBuildMismatch: ManagedHubBuildMismatchEvent | null;
|
||||
/** Local Hub setup, which runs after the transport is published. */
|
||||
hubStartup?: HubStartup;
|
||||
};
|
||||
export type BunRuntimeApi = {
|
||||
serve: (options: unknown) => { port: number; stop?: () => void };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AttachmentDropZone } from "@cline/ui";
|
||||
import { Loader2, LoaderCircle } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
Activity,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "react";
|
||||
import { AgentHeader } from "@/components/agent-header";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { HubStartupScreen } from "@/components/hub-startup-screen";
|
||||
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
|
||||
import { QuitHoldHandler } from "@/components/quit-hold-handler";
|
||||
import { SessionCommandBar } from "@/components/session-command-bar";
|
||||
@@ -55,11 +57,12 @@ import {
|
||||
import { AccountProvider, useAccount } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
|
||||
import type { ProcessContext } from "@/hooks/chat-session/types";
|
||||
import { checkForUpdateAndNotify, useAppUpdate } from "@/hooks/use-app-update";
|
||||
import { useChatSession } from "@/hooks/use-chat-session";
|
||||
import { useHubStartup } from "@/hooks/use-hub-startup";
|
||||
import { usePendingAttachments } from "@/hooks/use-pending-attachments";
|
||||
import { usePromptDraft } from "@/hooks/use-prompt-draft";
|
||||
import { useRemoteEnvironmentBootstrap } from "@/hooks/use-remote-environment-bootstrap";
|
||||
import { useSessionAgents } from "@/hooks/use-session-agents";
|
||||
import { useSessionHistory } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
@@ -147,10 +150,7 @@ import {
|
||||
type WorkIn,
|
||||
writeWorkInToWindow,
|
||||
} from "@/lib/work-in-selection";
|
||||
import {
|
||||
type RemoteWorkspaceEnvironment,
|
||||
remoteWorkspaceEnvironmentFromContext,
|
||||
} from "@/lib/workspace-environment";
|
||||
import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
|
||||
import {
|
||||
filterWorkspacePaths,
|
||||
isTaskWorktreePath,
|
||||
@@ -305,6 +305,32 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const startup = useHubStartup();
|
||||
const hubReady =
|
||||
startup.transport === "connected" && startup.hub.state === "ready";
|
||||
// Mount the app once the Hub is first ready, then hide it with Activity
|
||||
// whenever the Hub is not ready. Hidden Activity content keeps its state
|
||||
// (open conversations) but unmounts effects, so its dialogs, including
|
||||
// portaled ones, release focus traps and body pointer-events.
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (hubReady) setLoaded(true);
|
||||
}, [hubReady]);
|
||||
return (
|
||||
<>
|
||||
{loaded ? (
|
||||
<Activity mode={hubReady ? "visible" : "hidden"}>
|
||||
<App />
|
||||
</Activity>
|
||||
) : null}
|
||||
{hubReady ? null : <HubStartupScreen {...startup} />}
|
||||
{/* Outside the gate: the startup mismatch check can fire before App mounts. */}
|
||||
<HubUpdateRequiredDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [initialThreadId] = useState(makeThreadId);
|
||||
// Outlive keyed chat panes without re-rendering the app on every keystroke.
|
||||
const { current: promptDrafts } = useRef(new Map<string, string>());
|
||||
@@ -411,32 +437,27 @@ export default function Home() {
|
||||
void syncDesktopWindowTitle();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const revision = environmentSelectionRevision.current;
|
||||
desktopClient
|
||||
.invoke<ProcessContext>("get_process_context")
|
||||
.then((context) => {
|
||||
if (!cancelled && revision === environmentSelectionRevision.current) {
|
||||
const remoteEnvironment =
|
||||
remoteWorkspaceEnvironmentFromContext(context);
|
||||
setActiveRemoteEnvironment(remoteEnvironment);
|
||||
if (remoteEnvironment) {
|
||||
dispatchApp({
|
||||
type: "select-environment-draft",
|
||||
threadId: makeThreadId(),
|
||||
environmentId: remoteEnvironment.id,
|
||||
});
|
||||
}
|
||||
useRemoteEnvironmentBootstrap(
|
||||
environmentSelectionRevision,
|
||||
useCallback(
|
||||
(
|
||||
remoteEnvironment: RemoteWorkspaceEnvironment | null,
|
||||
isFirstLoad: boolean,
|
||||
) => {
|
||||
setActiveRemoteEnvironment(remoteEnvironment);
|
||||
// Later loads follow a reconnect; a new draft would replace the
|
||||
// conversation that is still open.
|
||||
if (isFirstLoad && remoteEnvironment) {
|
||||
dispatchApp({
|
||||
type: "select-environment-draft",
|
||||
threadId: makeThreadId(),
|
||||
environmentId: remoteEnvironment.id,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The chat bootstrap reports backend availability separately.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "chat") return;
|
||||
@@ -1071,7 +1092,6 @@ export default function Home() {
|
||||
onOpenChange={setExportDiagnosticsOpen}
|
||||
open={exportDiagnosticsOpen}
|
||||
/>
|
||||
<HubUpdateRequiredDialog />
|
||||
<QuitHoldHandler bindings={keybindings} />
|
||||
{whatsNew ? (
|
||||
<WhatsNewDialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { Activity, act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SpeechInput } from "./speech-input";
|
||||
@@ -487,6 +487,44 @@ describe("SpeechInput", () => {
|
||||
expect(button?.getAttribute("aria-label")).toBe("Record speech");
|
||||
});
|
||||
|
||||
it("returns to inactive after an Activity hides it mid-recording", async () => {
|
||||
const cancel = vi.fn();
|
||||
const onActiveChange = vi.fn();
|
||||
const render = (mode: "visible" | "hidden") =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<Activity mode={mode}>
|
||||
<SpeechInput
|
||||
onActiveChange={onActiveChange}
|
||||
onStartStreaming={async () => ({
|
||||
done: new Promise<void>(() => {}),
|
||||
stop: vi.fn(),
|
||||
cancel,
|
||||
})}
|
||||
recordingMode="streaming"
|
||||
/>
|
||||
</Activity>,
|
||||
);
|
||||
});
|
||||
await render("visible");
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Record speech"]',
|
||||
);
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
|
||||
|
||||
await render("hidden");
|
||||
await render("visible");
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(onActiveChange).toHaveBeenLastCalledWith(false);
|
||||
expect(button?.disabled).toBe(false);
|
||||
expect(button?.getAttribute("aria-label")).toBe("Record speech");
|
||||
});
|
||||
|
||||
it("returns to inactive when MediaRecorder rejects stop", async () => {
|
||||
const stopError = new DOMException("Already stopped", "InvalidStateError");
|
||||
const onActiveChange = vi.fn();
|
||||
|
||||
@@ -297,6 +297,10 @@ export function SpeechInput({
|
||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||
track.stop();
|
||||
}
|
||||
// A hidden Activity keeps this state; the cancelled recording can no
|
||||
// longer reset it, which would leave the composer read-only.
|
||||
setIsListening(false);
|
||||
setIsProcessing(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { HubStartupScreen } from "./hub-startup-screen";
|
||||
|
||||
const invoke = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
isTauriAvailable: () => false,
|
||||
}));
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
let root = createRoot(container);
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function buttons(): string[] {
|
||||
return [...container.querySelectorAll("button")].map(
|
||||
(button) => button.textContent ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
it("shows only a spinner while the Hub is starting", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<HubStartupScreen transport="connected" hub={{ state: "starting" }} />,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toContain("Starting Cline…");
|
||||
expect(buttons()).toEqual([]);
|
||||
// The window stays draggable (and closable on frameless Windows) without the app.
|
||||
expect(
|
||||
container.querySelector('[data-slot="window-title-bar"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("offers Retry and diagnostics export when the Hub fails", async () => {
|
||||
invoke.mockResolvedValue({ path: "/tmp/cline-diagnostics.txt" });
|
||||
act(() =>
|
||||
root.render(
|
||||
<HubStartupScreen
|
||||
transport="connected"
|
||||
hub={{ state: "failed", error: "Cline couldn't start its Hub." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toContain("Cline couldn't connect to its Hub");
|
||||
expect(buttons()).toEqual(["Retry", "Export diagnostics"]);
|
||||
|
||||
const [retry, exportButton] = container.querySelectorAll("button");
|
||||
act(() => retry.click());
|
||||
expect(invoke).toHaveBeenCalledWith("retry_hub");
|
||||
await act(async () => exportButton.click());
|
||||
expect(invoke).toHaveBeenCalledWith("export_diagnostics");
|
||||
expect(container.textContent).toContain(
|
||||
"Diagnostics saved to /tmp/cline-diagnostics.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls out an unreachable desktop backend after 30 seconds", () => {
|
||||
vi.useFakeTimers();
|
||||
act(() =>
|
||||
root.render(
|
||||
<HubStartupScreen transport="reconnecting" hub={{ state: "starting" }} />,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toContain("Starting Cline…");
|
||||
act(() => vi.advanceTimersByTime(30_000));
|
||||
expect(container.textContent).toContain(
|
||||
"Cline's desktop backend isn't responding",
|
||||
);
|
||||
expect(buttons()).toEqual(["Reload"]);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
WindowTitleBar,
|
||||
WindowTitleBarProvider,
|
||||
} from "@/components/window-title-bar";
|
||||
import type { useHubStartup } from "@/hooks/use-hub-startup";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
const BACKEND_UNRESPONSIVE_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Shown instead of the app while local Hub startup is in progress or has failed, so the
|
||||
* user gets Retry and a diagnostics export instead of Hub errors from
|
||||
* whichever feature they tried first.
|
||||
*/
|
||||
export function HubStartupScreen({
|
||||
transport,
|
||||
hub,
|
||||
}: ReturnType<typeof useHubStartup>) {
|
||||
const connected = transport === "connected";
|
||||
const [backendUnresponsive, setBackendUnresponsive] = useState(false);
|
||||
const [exportStatus, setExportStatus] = useState<string | null>(null);
|
||||
|
||||
// The client keeps reconnecting on its own; only call it out once it has
|
||||
// been unreachable for a while.
|
||||
useEffect(() => {
|
||||
setBackendUnresponsive(false);
|
||||
if (connected) return;
|
||||
const timer = setTimeout(
|
||||
() => setBackendUnresponsive(true),
|
||||
BACKEND_UNRESPONSIVE_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [connected]);
|
||||
|
||||
const exportDiagnostics = async () => {
|
||||
setExportStatus("Exporting diagnostics…");
|
||||
try {
|
||||
const result = await desktopClient.invoke<{ path: string }>(
|
||||
"export_diagnostics",
|
||||
);
|
||||
setExportStatus(`Diagnostics saved to ${result.path}`);
|
||||
} catch (error) {
|
||||
setExportStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
if (!connected && backendUnresponsive) {
|
||||
content = (
|
||||
<>
|
||||
<h1 className="text-base font-medium">
|
||||
Cline's desktop backend isn't responding
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cline keeps trying in the background. Reload to try again now.
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||
</>
|
||||
);
|
||||
} else if (connected && hub.state === "failed") {
|
||||
content = (
|
||||
<>
|
||||
<h1 className="text-base font-medium">
|
||||
Cline couldn't connect to its Hub
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hub.error} Retry, or export diagnostics and share the file with the
|
||||
Cline team if this keeps happening.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => void desktopClient.invoke("retry_hub")}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void exportDiagnostics()}>
|
||||
Export diagnostics
|
||||
</Button>
|
||||
</div>
|
||||
{exportStatus ? (
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{exportStatus}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<output className="text-sm text-muted-foreground">
|
||||
Starting Cline…
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Below dialogs (z-50) so the Hub update prompt stays on top. The title bar
|
||||
// keeps the drag region and, on frameless Windows, the window controls.
|
||||
return (
|
||||
<main className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
|
||||
<WindowTitleBarProvider contentEnabled={false}>
|
||||
<WindowTitleBar
|
||||
className="absolute inset-x-0 top-0"
|
||||
hostContent={false}
|
||||
/>
|
||||
</WindowTitleBarProvider>
|
||||
<div className="flex max-w-md flex-col items-center gap-4">{content}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, StrictMode } from "react";
|
||||
import { Activity, act, StrictMode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -433,6 +433,84 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("useChatSession", () => {
|
||||
it("keeps flushing streamed text after the app is hidden and restored", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const sendResponse = deferred<unknown>();
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
if (command === "get_process_context") {
|
||||
return {
|
||||
environmentId: "local",
|
||||
cwd: "/workspace/cline",
|
||||
workspaceRoot: "/workspace/cline",
|
||||
taskWorktreeRoot: "/home/host/cline-dir/worktrees",
|
||||
};
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
if (request?.action === "start")
|
||||
return { sessionId: request.config?.sessionId };
|
||||
if (request?.action === "send") return await sendResponse.promise;
|
||||
return { ok: true, prompts: [] };
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const renderIn = (mode: "visible" | "hidden") =>
|
||||
act(async () =>
|
||||
root.render(
|
||||
<Activity mode={mode}>
|
||||
<HookHarness />
|
||||
</Activity>,
|
||||
),
|
||||
);
|
||||
const latestChatHandler = () =>
|
||||
subscribeMock.mock.calls
|
||||
.filter(([eventName]) => eventName === "chat_event")
|
||||
.at(-1)?.[1] as (payload: unknown) => void;
|
||||
|
||||
await renderIn("visible");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await act(async () => {
|
||||
void current.sendPrompt("Say hello");
|
||||
for (let i = 0; i < 5; i += 1) await Promise.resolve();
|
||||
});
|
||||
const sessionId = current.sessionId;
|
||||
await act(async () => {
|
||||
latestChatHandler()({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "Hel",
|
||||
ts: 1,
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
// Hiding runs effect cleanups while the pending flush timer is armed.
|
||||
await renderIn("hidden");
|
||||
await renderIn("visible");
|
||||
await act(async () => {
|
||||
latestChatHandler()({
|
||||
sessionId,
|
||||
stream: "chat_text",
|
||||
chunk: "lo",
|
||||
ts: 2,
|
||||
index: 2,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
expect(
|
||||
current.messages.filter((message) => message.role === "assistant"),
|
||||
).toEqual([expect.objectContaining({ content: "Hello" })]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a remounted pane's newer draft when the original session start rejects", async () => {
|
||||
const drafts = new Map<string, string>();
|
||||
let draft!: ReturnType<typeof usePromptDraft>;
|
||||
|
||||
@@ -1074,6 +1074,7 @@ export function useChatSession(environmentId: string) {
|
||||
return () => {
|
||||
if (turnEndReconcileTimerRef.current !== null) {
|
||||
clearTimeout(turnEndReconcileTimerRef.current);
|
||||
turnEndReconcileTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -1419,6 +1420,9 @@ export function useChatSession(environmentId: string) {
|
||||
return () => {
|
||||
if (streamFlushTimerRef.current !== null) {
|
||||
clearTimeout(streamFlushTimerRef.current);
|
||||
// The app can be hidden and restored (Activity) with refs intact; a
|
||||
// stale id would make schedulePendingStreamFlush skip every flush.
|
||||
streamFlushTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useHubStartup } from "./use-hub-startup";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
transportHandler: undefined as ((state: string) => void) | undefined,
|
||||
hubHandler: undefined as ((payload: unknown) => void) | undefined,
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
getTransportState: () => "connected",
|
||||
subscribeTransportState: (handler: (state: string) => void) => {
|
||||
mocks.transportHandler = handler;
|
||||
return () => {};
|
||||
},
|
||||
subscribe: (_name: string, handler: (payload: unknown) => void) => {
|
||||
mocks.hubHandler = handler;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const container = document.createElement("div");
|
||||
let root = createRoot(container);
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { transport, hub } = useHubStartup();
|
||||
return <span>{`${transport}:${hub.state}`}</span>;
|
||||
}
|
||||
|
||||
it("forgets the previous sidecar's ready status when the transport drops", () => {
|
||||
act(() => root.render(<Probe />));
|
||||
act(() => mocks.hubHandler?.({ state: "ready" }));
|
||||
expect(container.textContent).toBe("connected:ready");
|
||||
|
||||
act(() => mocks.transportHandler?.("reconnecting"));
|
||||
expect(container.textContent).toBe("reconnecting:starting");
|
||||
|
||||
// The new sidecar has not replayed its status yet.
|
||||
act(() => mocks.transportHandler?.("connected"));
|
||||
expect(container.textContent).toBe("connected:starting");
|
||||
act(() => mocks.hubHandler?.({ state: "ready" }));
|
||||
expect(container.textContent).toBe("connected:ready");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
DesktopHubStatus,
|
||||
DesktopTransportState,
|
||||
} from "@/lib/desktop-transport";
|
||||
|
||||
/**
|
||||
* The sidecar sends `hub_status` whenever local Hub startup changes and
|
||||
* replays it to every new connection, so subscribing is enough.
|
||||
*/
|
||||
export function useHubStartup() {
|
||||
const [transport, setTransport] = useState<DesktopTransportState>(
|
||||
desktopClient.getTransportState(),
|
||||
);
|
||||
const [hub, setHub] = useState<DesktopHubStatus>({ state: "starting" });
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
desktopClient.subscribeTransportState((next) => {
|
||||
setTransport(next);
|
||||
// A restarted sidecar replays its own status on connect; never reuse
|
||||
// the previous sidecar's "ready".
|
||||
if (next !== "connected") setHub({ state: "starting" });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
desktopClient.subscribe("hub_status", (payload) => {
|
||||
if (payload && typeof payload === "object" && "state" in payload) {
|
||||
setHub(payload as DesktopHubStatus);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return { transport, hub };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
Activity,
|
||||
act,
|
||||
useCallback,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDesktopAppState,
|
||||
type DesktopAppAction,
|
||||
desktopAppReducer,
|
||||
} from "@/lib/desktop-app-state";
|
||||
import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
|
||||
import { useRemoteEnvironmentBootstrap } from "./use-remote-environment-bootstrap";
|
||||
|
||||
const invoke = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
|
||||
|
||||
const container = document.createElement("div");
|
||||
let root = createRoot(container);
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
vi.clearAllMocks();
|
||||
nextDraft = 0;
|
||||
});
|
||||
|
||||
let nextDraft = 0;
|
||||
let dispatchApp: (action: DesktopAppAction<string>) => void;
|
||||
|
||||
function Probe() {
|
||||
const revision = useRef(0);
|
||||
const [state, dispatch] = useReducer(
|
||||
desktopAppReducer<string>,
|
||||
createDesktopAppState("draft-0", "General", "local"),
|
||||
);
|
||||
dispatchApp = dispatch;
|
||||
const [environment, setEnvironment] =
|
||||
useState<RemoteWorkspaceEnvironment | null>(null);
|
||||
useRemoteEnvironmentBootstrap(
|
||||
revision,
|
||||
useCallback(
|
||||
(remote: RemoteWorkspaceEnvironment | null, isFirstLoad: boolean) => {
|
||||
setEnvironment(remote);
|
||||
if (isFirstLoad && remote) {
|
||||
dispatch({
|
||||
type: "select-environment-draft",
|
||||
threadId: `draft-${++nextDraft}`,
|
||||
environmentId: remote.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
),
|
||||
);
|
||||
return (
|
||||
<span>{`${environment?.id ?? "local"}:${state.navigation.current.activeThreadId}`}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function render(mode: "visible" | "hidden") {
|
||||
return act(async () =>
|
||||
root.render(
|
||||
<Activity mode={mode}>
|
||||
<Probe />
|
||||
</Activity>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it("keeps an open remote conversation when the app is shown again", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
remoteEnvironment: { id: "ssh-1" },
|
||||
homeDir: "/home/me",
|
||||
});
|
||||
await render("visible");
|
||||
expect(container.textContent).toBe("ssh-1:draft-1");
|
||||
act(() =>
|
||||
dispatchApp({
|
||||
type: "thread-started",
|
||||
threadId: "draft-1",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
);
|
||||
|
||||
await render("hidden");
|
||||
await render("visible");
|
||||
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
expect(container.textContent).toBe("ssh-1:draft-1");
|
||||
});
|
||||
|
||||
it("bootstraps navigation on the first load that succeeds", async () => {
|
||||
invoke.mockRejectedValueOnce(new Error("Hub starting")).mockResolvedValue({
|
||||
remoteEnvironment: { id: "ssh-1" },
|
||||
homeDir: "/home/me",
|
||||
});
|
||||
await render("visible");
|
||||
expect(container.textContent).toBe("local:draft-0");
|
||||
|
||||
await render("hidden");
|
||||
await render("visible");
|
||||
|
||||
expect(container.textContent).toBe("ssh-1:draft-1");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
import type { ProcessContext } from "@/hooks/chat-session/types";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
type RemoteWorkspaceEnvironment,
|
||||
remoteWorkspaceEnvironmentFromContext,
|
||||
} from "@/lib/workspace-environment";
|
||||
|
||||
/**
|
||||
* Reads the remote environment the sidecar is connected to. The app is hidden
|
||||
* with Activity while the Hub restarts, and showing it again re-runs this
|
||||
* effect, so `isFirstLoad` lets the caller refresh the environment on every
|
||||
* load but only bootstrap navigation once.
|
||||
*/
|
||||
export function useRemoteEnvironmentBootstrap(
|
||||
selectionRevision: RefObject<number>,
|
||||
onLoad: (
|
||||
environment: RemoteWorkspaceEnvironment | null,
|
||||
isFirstLoad: boolean,
|
||||
) => void,
|
||||
): void {
|
||||
const loadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const revision = selectionRevision.current;
|
||||
desktopClient
|
||||
.invoke<ProcessContext>("get_process_context")
|
||||
.then((context) => {
|
||||
if (cancelled || revision !== selectionRevision.current) return;
|
||||
const isFirstLoad = !loadedRef.current;
|
||||
loadedRef.current = true;
|
||||
onLoad(remoteWorkspaceEnvironmentFromContext(context), isFirstLoad);
|
||||
})
|
||||
.catch(() => {
|
||||
// The chat bootstrap reports backend availability separately.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectionRevision, onLoad]);
|
||||
}
|
||||
@@ -33,6 +33,13 @@ export type DesktopTransportMessage =
|
||||
| DesktopTransportResponse
|
||||
| DesktopTransportEvent;
|
||||
|
||||
/** Local Hub startup, sent as the `hub_status` event. */
|
||||
export type DesktopHubStatus = {
|
||||
state: "starting" | "ready" | "failed";
|
||||
/** User-facing summary. Raw errors go to the log and diagnostics report. */
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DesktopTransportState =
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
"devDependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.12.3",
|
||||
"@types/bun": "^1.4.2",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
@@ -2453,29 +2453,29 @@
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.3", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.3", "@tauri-apps/cli-darwin-x64": "2.12.3", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", "@tauri-apps/cli-linux-arm64-musl": "2.12.3", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-gnu": "2.12.3", "@tauri-apps/cli-linux-x64-musl": "2.12.3", "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", "@tauri-apps/cli-win32-x64-msvc": "2.12.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.3", "", { "os": "linux", "cpu": "arm" }, "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.3", "", { "os": "linux", "cpu": "none" }, "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.3", "", { "os": "win32", "cpu": "x64" }, "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ=="],
|
||||
|
||||
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
|
||||
|
||||
|
||||
@@ -861,6 +861,10 @@ Do not move these concerns into `@cline/agents`:
|
||||
- host-specific approvals
|
||||
- remote-config policy caching
|
||||
|
||||
Diagnostics derived from a run (for example run stall telemetry, see `DOC.md`)
|
||||
belong in a `core` observer of runtime events. `agents` only emits the events and
|
||||
merges host-supplied properties through `runEndTelemetryProperties`.
|
||||
|
||||
### Keep `core` Generic
|
||||
|
||||
Do not make `@cline/core` organization- or provider-specific.
|
||||
|
||||
+2
-2
@@ -143,7 +143,7 @@ Nightly release:
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly
|
||||
```
|
||||
|
||||
Nightly also runs on a schedule. It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
|
||||
Nightly is manual only (no schedule). It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
|
||||
|
||||
### Manual SDK Publish
|
||||
|
||||
@@ -185,7 +185,7 @@ bun pm ls @cline/core @cline/agents @cline/llms
|
||||
|
||||
### CI
|
||||
|
||||
The CI publish workflow (`.github/workflows/sdk-publish.yml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
|
||||
The CI publish workflow (`.github/workflows/sdk-publish.yml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch only. Every CLI and SDK publish job waits for a reviewer to approve the run in the `PublishNPM` GitHub environment.
|
||||
|
||||
### Root Automation Scope
|
||||
|
||||
|
||||
+25
@@ -471,3 +471,28 @@ malformed replies throw. It is optional on `McpManager` and
|
||||
tool calls on an already connected server. A read that starts while the server
|
||||
is being disconnected or re-registered can fail with a disconnect error; retry
|
||||
it.
|
||||
|
||||
## Run stall telemetry
|
||||
|
||||
`@cline/core` measures stalls with a `RunHealthObserver`
|
||||
(`packages/core/src/services/telemetry/run-health.ts`). `SessionRuntime`
|
||||
creates one per run, for root agents and subagents, when telemetry is
|
||||
configured. It watches only the run's runtime events and is disposed when the
|
||||
run returns.
|
||||
|
||||
- `task.run_idle` fires at most once per run, while it is still running and has
|
||||
emitted no runtime event for 60s.
|
||||
- When the run ends, `AgentRuntime` calls the optional
|
||||
`AgentRuntimeConfig.runEndTelemetryProperties()` once, while capturing
|
||||
`agent.run-finished` or `agent.run-failed`, and merges the result into that
|
||||
event. Core uses it to add `maxIdleMs`, `maxIdleAfter`, `msSinceLastEvent`,
|
||||
`lastEventType`, `finishReason`, `idleReported`, `endedOn` and
|
||||
`textSignature`. The observer stops its idle check at that point.
|
||||
- `tool-call-delta` is a runtime event emitted for each streamed tool-call
|
||||
argument chunk. It carries no arguments and exists so that a slow tool-call
|
||||
stream is not mistaken for a silent one. It is not mirrored to telemetry and
|
||||
is not forwarded as hub hook traffic.
|
||||
|
||||
No message text leaves the client: `textSignature` and `session.input_sent`'s
|
||||
`bumpKind` are enums computed locally
|
||||
(`packages/core/src/services/telemetry/stall-signals.ts`).
|
||||
|
||||
@@ -3321,6 +3321,49 @@ describe("AgentRuntime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports tool-argument streaming as progress and merges host run-end properties", async () => {
|
||||
const { capture, telemetry } = createTelemetryMock();
|
||||
const tool = createEchoTool();
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
{
|
||||
type: "tool-call-delta",
|
||||
toolCallId: "call-1",
|
||||
toolName: "echo",
|
||||
inputText: '{"text":',
|
||||
},
|
||||
{ type: "tool-call-delta", toolCallId: "call-1", inputText: '"hi"}' },
|
||||
{ type: "finish", reason: "tool-calls" },
|
||||
],
|
||||
() => [
|
||||
{ type: "text-delta", text: "done" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
]);
|
||||
const runtime = new AgentRuntime({
|
||||
model,
|
||||
tools: [tool],
|
||||
telemetry,
|
||||
runEndTelemetryProperties: () => ({ maxIdleMs: 42 }),
|
||||
});
|
||||
const eventTypes: string[] = [];
|
||||
runtime.subscribe((event) => eventTypes.push(event.type));
|
||||
|
||||
await runtime.run("Hi");
|
||||
|
||||
// Two chunks within a second report progress once.
|
||||
expect(eventTypes.filter((type) => type === "tool-call-delta")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
const events = capture.mock.calls.map(([input]) => input);
|
||||
expect(events.some((input) => input.event === "agent.tool-call-delta")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
events.find((input) => input.event === "agent.run-finished")?.properties,
|
||||
).toEqual(expect.objectContaining({ maxIdleMs: 42, status: "completed" }));
|
||||
});
|
||||
|
||||
it("captures task lifecycle telemetry around a successful model stream", async () => {
|
||||
const { capture, telemetry } = createTelemetryMock();
|
||||
const model = new ScriptedModel([
|
||||
|
||||
@@ -1795,6 +1795,7 @@ export class AgentRuntime {
|
||||
const content: AgentMessagePart[] = [];
|
||||
const toolAssemblies = new Map<string, PendingToolAssembly>();
|
||||
const modelToolActivities = new Map<string, AgentModelToolActivity>();
|
||||
let lastToolCallDeltaAt = 0;
|
||||
const invalidToolCalls: InvalidToolCall[] = [];
|
||||
const sequence: Array<
|
||||
{ type: "tool"; key: string } | { type: "part"; part: AgentMessagePart }
|
||||
@@ -1933,6 +1934,17 @@ export class AgentRuntime {
|
||||
event.inputText,
|
||||
);
|
||||
}
|
||||
// Only a progress signal: at most once a second, since each event
|
||||
// copies the whole conversation and a large file write streams
|
||||
// thousands of argument chunks.
|
||||
if (Date.now() - lastToolCallDeltaAt >= 1_000) {
|
||||
lastToolCallDeltaAt = Date.now();
|
||||
await this.emit({
|
||||
type: "tool-call-delta",
|
||||
snapshot: this.snapshot(),
|
||||
iteration: this.state.iteration,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool-result": {
|
||||
@@ -2757,8 +2769,19 @@ export class AgentRuntime {
|
||||
case "assistant-text-delta":
|
||||
case "assistant-reasoning-delta":
|
||||
case "assistant-media":
|
||||
case "tool-call-delta":
|
||||
case "tool-updated":
|
||||
break;
|
||||
case "run-finished":
|
||||
case "run-failed":
|
||||
this.config.telemetry?.capture({
|
||||
event: `agent.${event.type}`,
|
||||
properties: {
|
||||
...(metadata as TelemetryProperties),
|
||||
...this.config.runEndTelemetryProperties?.(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.config.telemetry?.capture({
|
||||
event: `agent.${event.type}`,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { zodToJsonSchema } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeRunCommandsInput } from "./helpers";
|
||||
import { RunCommandsInputSchema } from "./schemas";
|
||||
|
||||
describe("normalizeRunCommandsInput", () => {
|
||||
it("accepts the documented shapes", () => {
|
||||
expect(normalizeRunCommandsInput({ commands: ["ls", "pwd"] })).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
]);
|
||||
expect(normalizeRunCommandsInput({ commands: "ls" })).toEqual(["ls"]);
|
||||
expect(normalizeRunCommandsInput(["ls"])).toEqual(["ls"]);
|
||||
expect(normalizeRunCommandsInput("ls")).toEqual(["ls"]);
|
||||
expect(normalizeRunCommandsInput({ command: "ls" })).toEqual(["ls"]);
|
||||
expect(
|
||||
normalizeRunCommandsInput({ command: "git", args: ["status"] }),
|
||||
).toEqual([{ command: "git", args: ["status"] }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "an object with an empty commands list", input: { commands: [] } },
|
||||
{ label: "a bare empty list", input: [] },
|
||||
])("rejects $label instead of reporting an empty success", ({ input }) => {
|
||||
expect(() => normalizeRunCommandsInput(input)).toThrow(
|
||||
"run_commands requires at least one command",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunCommandsInputSchema", () => {
|
||||
it("advertises the command list as non-empty", () => {
|
||||
const schema = zodToJsonSchema(RunCommandsInputSchema) as {
|
||||
properties: { commands: { minItems?: number } };
|
||||
};
|
||||
expect(schema.properties.commands.minItems).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateWithZod } from "@cline/shared";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type EditFileInput,
|
||||
INPUT_ARG_CHAR_LIMIT,
|
||||
@@ -137,8 +138,24 @@ export function coalesceOrphanReadRanges(input: unknown): unknown {
|
||||
export function normalizeRunCommandsInput(
|
||||
input: unknown,
|
||||
): Array<string | StructuredCommandInput> {
|
||||
const validate = validateWithZod(RunCommandsInputUnionSchema, input);
|
||||
const commands = collectRunCommands(
|
||||
validateWithZod(RunCommandsInputUnionSchema, input),
|
||||
);
|
||||
// An empty list is well-formed but never a real request: nothing would
|
||||
// run and the call would still report success. Fail it so the model sees
|
||||
// that no command was given instead of an empty result it has to read
|
||||
// into.
|
||||
if (commands.length === 0) {
|
||||
throw new Error(
|
||||
"run_commands requires at least one command; the commands list was empty.",
|
||||
);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function collectRunCommands(
|
||||
validate: z.infer<typeof RunCommandsInputUnionSchema>,
|
||||
): Array<string | StructuredCommandInput> {
|
||||
if (typeof validate === "string") {
|
||||
return [validate];
|
||||
}
|
||||
|
||||
@@ -149,6 +149,10 @@ export const StructuredCommandEntrySchema = z.union([
|
||||
export const RunCommandsInputSchema = z.object({
|
||||
commands: z
|
||||
.array(CommandInputSchema)
|
||||
// Advertised to the model as minItems: 1. The lenient union below still
|
||||
// accepts an empty list so the normalizer can reject it with one clear
|
||||
// message instead of a union error.
|
||||
.min(1)
|
||||
.describe("Array of complete shell command strings to execute."),
|
||||
});
|
||||
|
||||
|
||||
@@ -508,6 +508,7 @@ function createToolProxies(
|
||||
const STREAMING_EVENT_TYPES = new Set<AgentRuntimeEvent["type"]>([
|
||||
"assistant-text-delta",
|
||||
"assistant-reasoning-delta",
|
||||
"tool-call-delta",
|
||||
"tool-updated",
|
||||
]);
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface CreateAgentRuntimeConfigInput {
|
||||
| Promise<AgentRuntimePrepareTurnResult | undefined>
|
||||
| AgentRuntimePrepareTurnResult
|
||||
| undefined;
|
||||
/** Host-derived diagnostics merged into the run-end telemetry. */
|
||||
readonly runEndTelemetryProperties?: AgentRuntimeConfig["runEndTelemetryProperties"];
|
||||
/** Seed messages (usually `session.conversation.getMessages()`). */
|
||||
readonly initialMessages?: readonly AgentMessage[];
|
||||
/**
|
||||
@@ -118,6 +120,7 @@ export function createAgentRuntimeConfig(
|
||||
plugins: input.plugins,
|
||||
logger: input.logger ?? agentConfig.logger,
|
||||
telemetry: input.telemetry ?? agentConfig.telemetry,
|
||||
runEndTelemetryProperties: input.runEndTelemetryProperties,
|
||||
initialMessages: input.initialMessages,
|
||||
completionPolicy:
|
||||
input.completionPolicy === null
|
||||
|
||||
@@ -566,6 +566,80 @@ describe("LocalRuntimeHost", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("labels prompts sent during a queued run but not the queued replay", async () => {
|
||||
const sessionId = "sess-queued-input";
|
||||
const capture = vi.fn();
|
||||
const telemetry = { capture, captureRequired: vi.fn() } as never;
|
||||
let busy = false;
|
||||
const agent = {
|
||||
run: vi.fn().mockResolvedValue(createResult()),
|
||||
continue: vi.fn().mockResolvedValue(createResult()),
|
||||
getMessages: vi.fn().mockReturnValue([]),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
canStartRun: vi.fn(() => !busy),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: createManifest(sessionId),
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
} as never,
|
||||
runtimeBuilder: {
|
||||
build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }),
|
||||
} as never,
|
||||
createAgent: () => agent as never,
|
||||
});
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({ telemetry, sessionId }),
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
const session = (
|
||||
manager as unknown as {
|
||||
sessions: Map<
|
||||
string,
|
||||
{ drainingPendingPrompts: boolean; replayingQueuedPrompt?: boolean }
|
||||
>;
|
||||
}
|
||||
).sessions.get(sessionId);
|
||||
if (!session) throw new Error("session not started");
|
||||
const inputSent = () =>
|
||||
capture.mock.calls
|
||||
.map(([e]) => e)
|
||||
.filter((e) => e.event === "session.input_sent")
|
||||
.map((e) => e.properties);
|
||||
|
||||
// The drain replays a queued prompt: only that input is unlabelled.
|
||||
session.drainingPendingPrompts = true;
|
||||
session.replayingQueuedPrompt = true;
|
||||
await manager.runTurn({ sessionId, prompt: "continue" });
|
||||
// The user reacts while that queued run is busy, and again just as
|
||||
// it goes idle: both are fresh input and stay labelled.
|
||||
busy = true;
|
||||
await manager.runTurn({ sessionId, prompt: "are you stuck?" });
|
||||
busy = false;
|
||||
await manager.runTurn({ sessionId, prompt: "hello?" });
|
||||
|
||||
const [replay, whileBusy, whileIdle] = inputSent();
|
||||
expect(replay).not.toHaveProperty("bumpKind");
|
||||
expect(whileBusy).toMatchObject({ agentBusy: true, bumpKind: "probe" });
|
||||
expect(whileIdle).toHaveProperty("bumpKind");
|
||||
});
|
||||
|
||||
it("resolves OAuth credentials before creating the agent", async () => {
|
||||
const sessionId = "sess-oauth-bootstrap";
|
||||
const manifest = createManifest(sessionId);
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
captureTaskCompleted,
|
||||
} from "../../services/telemetry/core-events";
|
||||
import { resolveCoreDistinctId } from "../../services/telemetry/distinct-id";
|
||||
import { classifyBumpText } from "../../services/telemetry/stall-signals";
|
||||
import {
|
||||
accumulateUsageTotals,
|
||||
createInitialAccumulatedUsage,
|
||||
@@ -1220,6 +1221,10 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
|
||||
async runTurn(input: SendSessionInput): Promise<AgentResult | undefined> {
|
||||
const session = this.getSessionOrThrow(input.sessionId);
|
||||
// Only the input the queue drain replays carries this marker; any
|
||||
// other input during a queued run is fresh user input.
|
||||
const replayingQueuedPrompt = session.replayingQueuedPrompt === true;
|
||||
session.replayingQueuedPrompt = false;
|
||||
const canStartRun = session.agent.canStartRun();
|
||||
const delivery =
|
||||
input.delivery ??
|
||||
@@ -1232,6 +1237,19 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
userImageCount: input.userImages?.length ?? 0,
|
||||
userFileCount: input.userFiles?.length ?? 0,
|
||||
delivery: delivery ?? "immediate",
|
||||
// A prompt sent into a busy run, or right after one ended, is how
|
||||
// users react to a stall; agent.run-finished carries the run's side.
|
||||
// A queued prompt replayed by the drain was labelled when the user
|
||||
// sent it, so its replay carries no stall fields.
|
||||
...(replayingQueuedPrompt
|
||||
? {}
|
||||
: {
|
||||
agentBusy: !canStartRun,
|
||||
bumpKind: classifyBumpText(input.prompt),
|
||||
...(session.lastTurnEndedAt === undefined
|
||||
? {}
|
||||
: { msSinceTurnEnd: Date.now() - session.lastTurnEndedAt }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (delivery === "queue" || delivery === "steer") {
|
||||
@@ -1935,6 +1953,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
session.lastTurnEndedAt = Date.now();
|
||||
await this.refreshActiveSessionGitMetadata(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ export class RuntimeEventAdapter {
|
||||
return this.translateAssistantMessage(event.message);
|
||||
case "tool-started":
|
||||
return this.translateToolStarted(event);
|
||||
case "tool-call-delta":
|
||||
return [];
|
||||
case "tool-updated":
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
captureMistakeLimitReached,
|
||||
captureSessionErrorRecorded,
|
||||
} from "../../services/telemetry/core-events";
|
||||
import { RunHealthObserver } from "../../services/telemetry/run-health";
|
||||
import { toPersistedToolResultContent } from "../../session/persisted-tool-result-content";
|
||||
import {
|
||||
DEFAULT_MAX_TOOL_RESULT_CHARS,
|
||||
@@ -1004,6 +1005,14 @@ export class SessionRuntime {
|
||||
.filter((tool) => tool.resultPolicy === "cache-oversized")
|
||||
.map((tool) => tool.name.toLowerCase()),
|
||||
);
|
||||
const runHealth =
|
||||
this.telemetry &&
|
||||
new RunHealthObserver(this.telemetry, {
|
||||
sessionId: this.config.sessionId,
|
||||
isSubagent: Boolean(this.parentAgentId),
|
||||
providerId: this.config.providerId,
|
||||
modelId: this.config.modelId,
|
||||
});
|
||||
const runtimeConfig = createAgentRuntimeConfig({
|
||||
agentConfig: this.config,
|
||||
sessionId: this.config.sessionId,
|
||||
@@ -1013,6 +1022,9 @@ export class SessionRuntime {
|
||||
model: agentModel,
|
||||
logger: this.logger,
|
||||
telemetry: this.telemetry,
|
||||
runEndTelemetryProperties: runHealth
|
||||
? () => runHealth.endProperties()
|
||||
: undefined,
|
||||
tools,
|
||||
toolContextMetadata: {
|
||||
modelSupportsImages: modelSupportsImageInput(modelInfo ?? {}),
|
||||
@@ -1037,6 +1049,7 @@ export class SessionRuntime {
|
||||
if (event.type === "run-started" && this.abortRequested) {
|
||||
runtime.abort(this.abortReason);
|
||||
}
|
||||
runHealth?.observe(event);
|
||||
this.handleRuntimeEvent(event);
|
||||
});
|
||||
if (this.config.abortSignal) {
|
||||
@@ -1067,6 +1080,7 @@ export class SessionRuntime {
|
||||
thrownError = error instanceof Error ? error : new Error(String(error));
|
||||
} finally {
|
||||
unsubscribe();
|
||||
runHealth?.dispose();
|
||||
this.config.abortSignal?.removeEventListener(
|
||||
"abort",
|
||||
this.handleExternalAbort,
|
||||
|
||||
@@ -340,6 +340,7 @@ export class PendingPromptsController {
|
||||
session.drainingPendingPrompts = true;
|
||||
let continueDrain = true;
|
||||
try {
|
||||
session.replayingQueuedPrompt = true;
|
||||
const result = await this.deps.send({
|
||||
sessionId,
|
||||
prompt: next.prompt,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
captureMistakeLimitReached,
|
||||
captureProviderConfigured,
|
||||
captureRunCommandsTimeout,
|
||||
captureRunIdle,
|
||||
captureScheduleRun,
|
||||
captureTaskCompleted,
|
||||
captureTaskCreated,
|
||||
@@ -393,6 +394,31 @@ describe("captureWorkspacePathResolved", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureRunIdle", () => {
|
||||
test("emits task.run_idle and drops a missing session id", () => {
|
||||
const stub = createTelemetryStub();
|
||||
captureRunIdle(stub.telemetry, {
|
||||
ulid: undefined,
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
idleMs: 60_000,
|
||||
lastEventType: "tool-started",
|
||||
isSubagent: true,
|
||||
});
|
||||
expect(stub.captureRequired).not.toHaveBeenCalled();
|
||||
expect(captureCallAt(stub, 0)).toEqual({
|
||||
event: CORE_TELEMETRY_EVENTS.TASK.RUN_IDLE,
|
||||
properties: {
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
idleMs: 60_000,
|
||||
lastEventType: "tool-started",
|
||||
isSubagent: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureMistakeLimitReached", () => {
|
||||
const baseProps = {
|
||||
ulid: "sess-1",
|
||||
|
||||
@@ -85,6 +85,7 @@ export const CORE_TELEMETRY_EVENTS = {
|
||||
PROVIDER_STREAM_FAILED: TASK_PROVIDER_STREAM_FAILED_EVENT,
|
||||
MAX_TOKENS_RECOVERY: TASK_MAX_TOKENS_RECOVERY_EVENT,
|
||||
CANCELLED: TASK_CANCELLED_EVENT,
|
||||
RUN_IDLE: "task.run_idle",
|
||||
MENTION_USED: "task.mention_used",
|
||||
MENTION_FAILED: "task.mention_failed",
|
||||
MENTION_SEARCH_RESULTS: "task.mention_search_results",
|
||||
@@ -645,6 +646,30 @@ export function captureMistakeLimitReached(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* At most once per agent run, while it is still running and has emitted no
|
||||
* runtime event (stream deltas included) for `idleMs`. Sent while stuck so
|
||||
* hangs the user escapes by quitting the app are still counted.
|
||||
*/
|
||||
export function captureRunIdle(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
properties: {
|
||||
ulid?: string;
|
||||
agentId: string;
|
||||
runId: string;
|
||||
idleMs: number;
|
||||
/** Runtime event type that preceded the silence. */
|
||||
lastEventType: string;
|
||||
isSubagent: boolean;
|
||||
},
|
||||
): void {
|
||||
emit(
|
||||
telemetry,
|
||||
CORE_TELEMETRY_EVENTS.TASK.RUN_IDLE,
|
||||
stripUndefinedProperties(properties),
|
||||
);
|
||||
}
|
||||
|
||||
export function captureRunCommandsTimeout(
|
||||
telemetry: ITelemetryService | undefined,
|
||||
properties: RunCommandsTimeoutTelemetryProperties,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { AgentMessage, AgentRuntimeEvent } from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RUN_IDLE_REPORT_MS, RunHealthObserver } from "./run-health";
|
||||
|
||||
function event(type: string, extra: object = {}): AgentRuntimeEvent {
|
||||
return {
|
||||
type,
|
||||
snapshot: { agentId: "agent-1", runId: "run-1" },
|
||||
...extra,
|
||||
} as unknown as AgentRuntimeEvent;
|
||||
}
|
||||
|
||||
function assistantText(text: string): AgentRuntimeEvent {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
} as AgentMessage;
|
||||
return event("assistant-message", { message, finishReason: "stop" });
|
||||
}
|
||||
|
||||
describe("RunHealthObserver", () => {
|
||||
let capture: ReturnType<typeof vi.fn>;
|
||||
let observer: RunHealthObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
capture = vi.fn();
|
||||
observer = new RunHealthObserver({ capture } as never, {
|
||||
sessionId: "sess-1",
|
||||
isSubagent: false,
|
||||
providerId: "cline",
|
||||
modelId: "deepseek-v4-flash",
|
||||
});
|
||||
observer.observe(event("run-started"));
|
||||
observer.observe(event("turn-started"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
observer.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const idleEvents = () =>
|
||||
capture.mock.calls.filter(([call]) => call.event === "task.run_idle");
|
||||
|
||||
it("reports a silent stream once while stuck and summarizes the run", () => {
|
||||
observer.observe(
|
||||
event("assistant-text-delta", { accumulatedText: "Let me check:" }),
|
||||
);
|
||||
vi.advanceTimersByTime(150_000);
|
||||
observer.observe(assistantText("Let me check the file:"));
|
||||
|
||||
expect(idleEvents()).toEqual([
|
||||
[
|
||||
{
|
||||
event: "task.run_idle",
|
||||
properties: {
|
||||
ulid: "sess-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
idleMs: RUN_IDLE_REPORT_MS,
|
||||
lastEventType: "assistant-text-delta",
|
||||
isSubagent: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(observer.endProperties()).toMatchObject({
|
||||
sessionId: "sess-1",
|
||||
isSubagent: false,
|
||||
providerId: "cline",
|
||||
modelId: "deepseek-v4-flash",
|
||||
maxIdleMs: 150_000,
|
||||
maxIdleAfter: "assistant-text-delta",
|
||||
idleReported: true,
|
||||
finishReason: "stop",
|
||||
endedOn: "assistant_text",
|
||||
textSignature: "intent_without_action",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats streamed tool-call arguments as progress", () => {
|
||||
for (let elapsed = 0; elapsed < 3 * RUN_IDLE_REPORT_MS; elapsed += 10_000) {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
observer.observe(event("tool-call-delta"));
|
||||
}
|
||||
expect(idleEvents()).toEqual([]);
|
||||
expect(observer.endProperties()).toMatchObject({
|
||||
maxIdleMs: 10_000,
|
||||
idleReported: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts the silence before the run ends", () => {
|
||||
observer.observe(assistantText("Done."));
|
||||
vi.advanceTimersByTime(90_000);
|
||||
expect(observer.endProperties()).toMatchObject({
|
||||
maxIdleMs: 90_000,
|
||||
maxIdleAfter: "assistant-message",
|
||||
msSinceLastEvent: 90_000,
|
||||
idleReported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("labels a turn rejected before reaching history as empty", () => {
|
||||
observer.observe(assistantText("I'll run the tests now:"));
|
||||
observer.observe(event("turn-started"));
|
||||
observer.observe(event("run-failed"));
|
||||
expect(observer.endProperties()).toMatchObject({
|
||||
endedOn: "empty",
|
||||
textSignature: "empty_turn",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not sign a turn that failed mid-stream", () => {
|
||||
observer.observe(event("assistant-text-delta"));
|
||||
const properties = observer.endProperties();
|
||||
expect(properties.endedOn).toBe("unfinished");
|
||||
expect(properties).not.toHaveProperty("textSignature");
|
||||
});
|
||||
|
||||
it("does not sign a media-only reply or carry an earlier turn's finish reason", () => {
|
||||
observer.observe(assistantText("Calling a tool."));
|
||||
observer.observe(event("turn-started"));
|
||||
observer.observe(
|
||||
event("assistant-message", {
|
||||
message: { role: "assistant", content: [{ type: "media" }] },
|
||||
finishReason: "stop",
|
||||
}),
|
||||
);
|
||||
const media = observer.endProperties();
|
||||
expect(media.endedOn).toBe("assistant_other");
|
||||
expect(media).not.toHaveProperty("textSignature");
|
||||
|
||||
observer.observe(event("turn-started"));
|
||||
expect(observer.endProperties().finishReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops checking once the run ends", () => {
|
||||
observer.endProperties();
|
||||
vi.advanceTimersByTime(5 * RUN_IDLE_REPORT_MS);
|
||||
expect(idleEvents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
AgentMessage,
|
||||
AgentModelFinishReason,
|
||||
AgentRuntimeEvent,
|
||||
ITelemetryService,
|
||||
TelemetryProperties,
|
||||
} from "@cline/shared";
|
||||
import { captureRunIdle } from "./core-events";
|
||||
import { classifyFinalTurnText } from "./stall-signals";
|
||||
|
||||
/** Silence (no runtime event, stream deltas included) before a run reports `task.run_idle`. */
|
||||
export const RUN_IDLE_REPORT_MS = 60_000;
|
||||
|
||||
/** Coarse shape of a message; never carries content. */
|
||||
function messageShape(message: AgentMessage): string {
|
||||
if (message.content.length === 0) {
|
||||
return "empty";
|
||||
}
|
||||
const types = new Set(message.content.map((part) => part.type));
|
||||
if (types.has("tool-call")) return "tool_call";
|
||||
return `assistant_${types.has("text") ? "text" : "other"}`;
|
||||
}
|
||||
|
||||
function messageText(message: AgentMessage): string {
|
||||
return message.content
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stall diagnostics for one agent run, derived only from its runtime events.
|
||||
* `endProperties()` enriches the run's `agent.run-finished` /
|
||||
* `agent.run-failed` telemetry; `task.run_idle` is sent while the run is
|
||||
* still stuck.
|
||||
*/
|
||||
export class RunHealthObserver {
|
||||
private lastEventAt = Date.now();
|
||||
private lastEventType = "";
|
||||
private maxIdleMs = 0;
|
||||
private maxIdleAfter = "";
|
||||
private idleReported = false;
|
||||
private agentId = "";
|
||||
private runId = "";
|
||||
private finishReason?: AgentModelFinishReason;
|
||||
private turnStarted = false;
|
||||
/** The current turn streamed something but has no assistant message yet. */
|
||||
private turnHadOutput = false;
|
||||
/** The current turn's assistant message, once the runtime accepts one. */
|
||||
private turnMessage?: AgentMessage;
|
||||
private idleTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(
|
||||
private readonly telemetry: ITelemetryService,
|
||||
private readonly context: {
|
||||
sessionId?: string;
|
||||
isSubagent: boolean;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
},
|
||||
) {
|
||||
this.scheduleIdleCheck();
|
||||
}
|
||||
|
||||
observe(event: AgentRuntimeEvent): void {
|
||||
this.recordGap();
|
||||
this.lastEventType = event.type;
|
||||
this.agentId = event.snapshot.agentId;
|
||||
this.runId = event.snapshot.runId ?? "";
|
||||
switch (event.type) {
|
||||
case "turn-started":
|
||||
this.turnStarted = true;
|
||||
this.turnHadOutput = false;
|
||||
this.turnMessage = undefined;
|
||||
this.finishReason = undefined;
|
||||
break;
|
||||
case "assistant-text-delta":
|
||||
case "assistant-reasoning-delta":
|
||||
case "assistant-media":
|
||||
case "tool-call-delta":
|
||||
this.turnHadOutput = true;
|
||||
break;
|
||||
case "assistant-message":
|
||||
this.turnMessage = event.message;
|
||||
this.finishReason = event.finishReason;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read once when the run ends; stops the idle check. */
|
||||
endProperties(): TelemetryProperties {
|
||||
this.dispose();
|
||||
const msSinceLastEvent = Date.now() - this.lastEventAt;
|
||||
this.recordGap();
|
||||
// A turn the runtime rejected before it reached history (an empty
|
||||
// response) still counts as the final turn, and as empty.
|
||||
const endedOn = this.turnMessage
|
||||
? messageShape(this.turnMessage)
|
||||
: !this.turnStarted
|
||||
? "none"
|
||||
: this.turnHadOutput
|
||||
? "unfinished"
|
||||
: "empty";
|
||||
// agent.* events carry no session or model of their own; these let a
|
||||
// run be grouped by model and paired with the user's next message.
|
||||
const { sessionId, isSubagent, providerId, modelId } = this.context;
|
||||
return {
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
isSubagent,
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
maxIdleMs: this.maxIdleMs,
|
||||
maxIdleAfter: this.maxIdleAfter,
|
||||
idleReported: this.idleReported,
|
||||
msSinceLastEvent,
|
||||
lastEventType: this.lastEventType,
|
||||
finishReason: this.finishReason,
|
||||
endedOn,
|
||||
// Only a text or empty final turn has a text signature (detector
|
||||
// parity); a media-only reply is not an empty one.
|
||||
...(endedOn === "empty" || endedOn === "assistant_text"
|
||||
? {
|
||||
textSignature: classifyFinalTurnText(
|
||||
this.turnMessage ? messageText(this.turnMessage) : "",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = undefined;
|
||||
}
|
||||
|
||||
private recordGap(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastEventAt > this.maxIdleMs) {
|
||||
this.maxIdleMs = now - this.lastEventAt;
|
||||
this.maxIdleAfter = this.lastEventType;
|
||||
}
|
||||
this.lastEventAt = now;
|
||||
}
|
||||
|
||||
/** One pending timer per run, re-armed from the last event rather than per event. */
|
||||
private scheduleIdleCheck(): void {
|
||||
const delayMs = this.lastEventAt + RUN_IDLE_REPORT_MS - Date.now();
|
||||
this.idleTimer = setTimeout(
|
||||
() => {
|
||||
const idleMs = Date.now() - this.lastEventAt;
|
||||
if (idleMs < RUN_IDLE_REPORT_MS) {
|
||||
this.scheduleIdleCheck();
|
||||
return;
|
||||
}
|
||||
this.idleReported = true;
|
||||
this.idleTimer = undefined;
|
||||
captureRunIdle(this.telemetry, {
|
||||
ulid: this.context.sessionId,
|
||||
agentId: this.agentId,
|
||||
runId: this.runId,
|
||||
idleMs,
|
||||
lastEventType: this.lastEventType,
|
||||
isSubagent: this.context.isSubagent,
|
||||
});
|
||||
},
|
||||
Math.max(delayMs, 0),
|
||||
);
|
||||
this.idleTimer.unref?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyBumpText, classifyFinalTurnText } from "./stall-signals";
|
||||
|
||||
// Expected labels are what the offline Python detector returns for the same text.
|
||||
describe("stall signals", () => {
|
||||
it.each([
|
||||
["continue", "nudge"],
|
||||
["ok continue", "nudge"],
|
||||
["продолжай", "nudge"],
|
||||
["继续", "nudge"],
|
||||
["can you pick up where you left off?", "nudge"],
|
||||
["are you stuck?", "probe"],
|
||||
["did it finish?", "probe"],
|
||||
["status?", "probe"],
|
||||
["...", "probe"],
|
||||
["还在吗", "probe"],
|
||||
["hello", "greeting"],
|
||||
[
|
||||
"I'm stuck on page 1 of the docs, can you help me with the layout?",
|
||||
"none",
|
||||
],
|
||||
["go ahead and refactor the payment module to use the new client", "none"],
|
||||
["fix the failing test in auth.ts", "none"],
|
||||
])("bump: %s → %s", (text, kind) => {
|
||||
expect(classifyBumpText(text)).toBe(kind);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "empty_turn"],
|
||||
["API error: 529 overloaded_error", "api_error"],
|
||||
["Here is the change:\n```ts\nconst x = 1", "truncated_text"],
|
||||
["I updated the config and the", "truncated_text"],
|
||||
["Now let me run the tests:", "intent_without_action"],
|
||||
[
|
||||
"Summary of changes.\n\nLet me run the linter to verify:",
|
||||
"intent_without_action",
|
||||
],
|
||||
["I'll ping you when the build finishes.", "async_promise"],
|
||||
["Should I proceed with the migration?", "none"],
|
||||
["Let me know if you want me to continue.", "none"],
|
||||
["Changes:\n- added the toggle\n- removed the old flag", "none"],
|
||||
["The fix is in place 🎉", "none"],
|
||||
])("final text: %j → %s", (text, signature) => {
|
||||
expect(classifyFinalTurnText(text)).toBe(signature);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Stall signals ported from the offline (Python) stall detector, so the harness
|
||||
* can label runs live and send only the verdict, never the text.
|
||||
*
|
||||
* Python's `\w`/`\b` match letters in any script; JS's match ASCII only, even
|
||||
* with the `u` flag, so spots that meet non-ASCII words use `\p{L}` instead.
|
||||
*/
|
||||
|
||||
export type StallBumpKind = "probe" | "nudge" | "greeting" | "none";
|
||||
export type StallTextSignature =
|
||||
| "empty_turn"
|
||||
| "api_error"
|
||||
| "truncated_text"
|
||||
| "async_promise"
|
||||
| "intent_without_action"
|
||||
| "none";
|
||||
|
||||
const re = (source: string) => new RegExp(source, "iu");
|
||||
|
||||
// User reacting to a stall: "are you stuck?", "continue", "hello?"
|
||||
const PROBE_RES = [
|
||||
String.raw`\b(?:are|r)\s+(?:you|u)\s+(?:still\s+)?(?:stuck|dead|there|alive|ok(?:ay)?|frozen|hung|hanging|working|running|awake|on it|done|finished|thinking|doing anything|responding|back)\b`,
|
||||
String.raw`\b(?:you|u)\s+(?:still\s+)?(?:stuck|dead|alive|frozen|hung)\b`,
|
||||
String.raw`\b(?:did|has|have)\s+(?:you|it|this|that|the\s+\w+)(?:\s+\w+)?\s+(?:finish(?:ed)?|complete[d]?|stop(?:ped)?|die[d]?|hang|hung|crash(?:ed)?|freeze|froze|get\s+stuck|time[d]?\s*out)\b`,
|
||||
String.raw`\b(?:is|was)\s+(?:it|this|that|the\s+\w+)\s+(?:still\s+)?(?:stuck|hung|frozen|running|done|finished|working)\b`,
|
||||
String.raw`\bwhy\s+(?:did|have|has)\s+(?:you|it)\s+stop(?:ped)?\b|\b(?:you|it)\s+(?:just\s+)?stopped\b`,
|
||||
String.raw`\b(?:seems?|appears?|looks?)\s+(?:to\s+be\s+|like\s+(?:you(?:'re| are)|it(?:'s| is))\s+)?(?:stuck|frozen|hung|dead)\b`,
|
||||
String.raw`\bwhat(?:'s|\s+is)?\s+(?:happening|going\s+on)\b|\bwhat\s+happened\b|\bany\s+(?:update|progress)\b|^\s*(?:status|update|progress)\s*\?+\s*$`,
|
||||
String.raw`\bprevious\s+(?:\w+\s+){0,4}(?:response|answer|reply|output)\s+(?:was|got)\s+(?:incomplete|cut\s+off|truncated|interrupted)|\bincomplete\s+or\s+timed\s+out\b`,
|
||||
String.raw`\byou\s+(?:got\s+)?cut\s+off\b(?!\s+by\s+my)|\b(?:your\s+)?(?:response|answer|reply)\s+(?:got\s+|was\s+)?cut\s+off\b(?!\s+by\s+my)`,
|
||||
String.raw`^(?:anyone\s+there|you\s+there|wake\s+up)\s*[?!.]*$`,
|
||||
String.raw`^[??]+$|^\.{3,}$|^.{0,6}[??]{3,}$`,
|
||||
String.raw`还在吗|在吗[??]?$|怎么不动|你怎么停|怎么停了|止まった|固まった|フリーズ|멈췄|завис|застрял|bist\s+du\s+(?:noch\s+)?da`,
|
||||
].map(re);
|
||||
const BARE_STATE_RE = re(
|
||||
String.raw`\b(?:stuck|hung|hanging|frozen|unresponsive|not\s+responding|no\s+response)\b|卡住|卡了|没反应|不动了`,
|
||||
);
|
||||
const NUDGE_RE = re(
|
||||
String.raw`^(?:(?:ok(?:ay)?|k|so|well|please|pls|now|yes|yeah)[,.!]?\s+)*` +
|
||||
String.raw`(?:continue|contine|contiune|keep\s+going|keep\s+working|go\s+on|go\s+ahead|go|proceed|resume|carry\s+on|next|finish(?:\s+it|\s+up)?|do\s+it|try\s+again|retry|again|` +
|
||||
String.raw`继续|繼續|続けて|계속|продолжа\p{L}*|дальше|давай|contin[uú]a|sigue|weiter|mach\s+weiter|continuez)` +
|
||||
String.raw`(?:(?![\p{L}\p{N}_])|(?<=[-ヿ一-鿿]))[\s\p{L}\p{N}_,.!?()'’-]{0,40}$`,
|
||||
);
|
||||
const RESUME_REQUEST_RE = re(
|
||||
String.raw`\b(?:pick\s+(?:it\s+)?(?:back\s+)?up|continue|carry\s+on|resume|keep\s+going)\s+(?:from\s+)?where\s+(?:you|we)\s+(?:left\s+off|stopped|were)\b|\bwhere\s+were\s+we\b`,
|
||||
);
|
||||
const GREETING_RE = re(
|
||||
String.raw`^(?:hello+|hi+|hey+|ping|yo|你好|在吗)\s*[??!.]*$`,
|
||||
);
|
||||
|
||||
// Final assistant turn that looks stalled: cut off, an error, or a promise not kept.
|
||||
const API_ERROR_TEXT_RE = re(
|
||||
String.raw`^\s*(?:API error|API Request Failed|Error:\s*(?:Request|Stream|Connection))|stream-first-event-timeout|Request timed out|socket hang up|ECONNRESET|overloaded_error|upstream connect error|Provider returned error|terminated due to timeout`,
|
||||
);
|
||||
const TERMINAL_RE = re(
|
||||
String.raw`(?:[.!?:;)\]}"'\x60*>|_~。!?:)」』…]|[←-⯿\u{1F000}-\u{1FAFF}☀-➿️])\s*$`,
|
||||
);
|
||||
const LIST_TAIL_RE = re(String.raw`\n\s*(?:[-*•]|\d+[.)])\s+\S[^\n]*$`);
|
||||
const ASYNC_PROMISE_RE = re(
|
||||
String.raw`\b(?:will|I'll|I\s+will)\s+(?:ping|notify|report|update|let\s+you\s+know|check\s+back|get\s+back)\b[^\n]{0,80}\b(?:when|once|after|as\s+soon\s+as)\b|\b(?:wait(?:ing)?\s+for\s+(?:it|the\s+\w+)\s+to\s+(?:finish|complete|land))\b`,
|
||||
);
|
||||
const INTENT_RE = re(
|
||||
String.raw`(?:^|[\n.!:]\s*)(?:now\s+|next[,:]?\s+|then\s+|first[,:]?\s+|ok(?:ay)?[,.]\s+|great[,.!]\s+)?(?:let\s+me|let's|i'll|i\s+will|i'm\s+going\s+to|i\s+am\s+going\s+to|i'm\s+now|going\s+to|launching|starting|running|proceeding|continuing|moving\s+on|time\s+to)\b[^\n]{0,240}[.:…]?\s*$`,
|
||||
);
|
||||
const ASKS_USER_RE = re(
|
||||
String.raw`\?\s*$|\b(?:want\s+me\s+to|should\s+i|shall\s+i|would\s+you\s+like|do\s+you\s+want|let\s+me\s+know|approve|confirm|which\s+(?:option|one|approach)|ready\s+to\s+proceed|when\s+you(?:'re|\s+are)\s+ready|ping\s+me|once\s+you)\b`,
|
||||
);
|
||||
|
||||
export function classifyBumpText(prompt: string): StallBumpKind {
|
||||
const text = prompt.trim();
|
||||
const length = Array.from(text).length;
|
||||
if (!text || length > 200) return "none";
|
||||
if (PROBE_RES.some((pattern) => pattern.test(text))) return "probe";
|
||||
if (length <= 40 && BARE_STATE_RE.test(text)) return "probe";
|
||||
if (length <= 80 && NUDGE_RE.test(text)) return "nudge";
|
||||
if (RESUME_REQUEST_RE.test(text)) return "nudge";
|
||||
if (GREETING_RE.test(text)) return "greeting";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Signature of a run's final text-only assistant turn. */
|
||||
export function classifyFinalTurnText(rawText: string): StallTextSignature {
|
||||
const text = rawText.trimEnd();
|
||||
if (!text) return "empty_turn";
|
||||
if (text.length < 600 && API_ERROR_TEXT_RE.test(text)) return "api_error";
|
||||
if ((text.split("```").length - 1) % 2 === 1) return "truncated_text";
|
||||
if (!TERMINAL_RE.test(text) && !LIST_TAIL_RE.test(text))
|
||||
return "truncated_text";
|
||||
const lastPara = text.slice(text.lastIndexOf("\n\n") + 1);
|
||||
if (ASYNC_PROMISE_RE.test(lastPara)) return "async_promise";
|
||||
if (INTENT_RE.test(lastPara) && !ASKS_USER_RE.test(lastPara.slice(-300)))
|
||||
return "intent_without_action";
|
||||
return "none";
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export type ActiveSession = {
|
||||
agent: SessionRuntime;
|
||||
started: boolean;
|
||||
aborting: boolean;
|
||||
/** Epoch ms the last turn ended; labels the next input for stall analysis. */
|
||||
lastTurnEndedAt?: number;
|
||||
interactive: boolean;
|
||||
persistedMessages?: LlmsProviders.MessageWithMetadata[];
|
||||
compactionState?: SessionCompactionState;
|
||||
@@ -34,6 +36,8 @@ export type ActiveSession = {
|
||||
teamRunWaiters: Array<() => void>;
|
||||
pendingPrompts: PendingPrompt[];
|
||||
drainingPendingPrompts: boolean;
|
||||
/** Set by the queue drain on the one input it replays; consumed by runTurn. */
|
||||
replayingQueuedPrompt?: boolean;
|
||||
pluginSandboxShutdown?: () => Promise<void>;
|
||||
pluginCommandError?: string;
|
||||
pluginCommandTail?: Promise<unknown>;
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
ToolPolicy,
|
||||
} from "./llms/tools";
|
||||
import type { BasicLogger } from "./logging/logger";
|
||||
import type { ITelemetryService } from "./services/telemetry";
|
||||
import type {
|
||||
ITelemetryService,
|
||||
TelemetryProperties,
|
||||
} from "./services/telemetry";
|
||||
|
||||
// =============================================================================
|
||||
// Lightweight telemetry surface used by AgentRuntime
|
||||
@@ -579,6 +582,12 @@ export interface AgentRuntimeConfig {
|
||||
plugins?: readonly AgentRuntimePlugin[];
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
/**
|
||||
* Extra properties for the `agent.run-finished` / `agent.run-failed`
|
||||
* telemetry mirror, read once when the run ends. Lets the host attach
|
||||
* diagnostics it derives from runtime events without the loop owning them.
|
||||
*/
|
||||
runEndTelemetryProperties?: () => TelemetryProperties;
|
||||
initialMessages?: readonly AgentMessage[];
|
||||
maxIterations?: number;
|
||||
completionPolicy?: {
|
||||
@@ -661,6 +670,16 @@ export type AgentRuntimeEvent =
|
||||
message: AgentMessage;
|
||||
finishReason: AgentModelFinishReason;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Progress while the model streams a tool call's arguments. Carries
|
||||
* no arguments: consumers use it only to tell a slow stream from a
|
||||
* silent one.
|
||||
*/
|
||||
type: "tool-call-delta";
|
||||
snapshot: AgentRuntimeStateSnapshot;
|
||||
iteration: number;
|
||||
}
|
||||
| {
|
||||
type: "tool-started";
|
||||
snapshot: AgentRuntimeStateSnapshot;
|
||||
|
||||
Reference in New Issue
Block a user