From b142cd1a3824b94e26e0777de537f4e80916d115 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 22 Aug 2026 02:16:00 -0400 Subject: [PATCH 1/8] feat(server): add Kilo Code provider over ACP Kilo Code (the @kilocode/cli binary, `kilo acp`) speaks Agent Client Protocol v1 over stdio with load/resume sessions and a config-option model catalog. This wires it in as a built-in driver alongside the Cursor and Grok ACP drivers: driver + adapter + status probe + text generation on the shared ACP runtime, contracts settings, web/mobile presentation, docs, and mock-agent test coverage including an env-gated probe against a real install. --- AGENTS.md | 4 +- README.md | 5 +- apps/marketing/public/harnesses/kilo-dark.svg | 4 + apps/marketing/src/pages/index.astro | 11 +- apps/mobile/src/components/ProviderIcon.tsx | 12 + .../features/threads/NewTaskDraftScreen.tsx | 17 +- .../src/features/threads/ThreadComposer.tsx | 21 +- .../threads/new-task-flow-provider.tsx | 7 + apps/mobile/src/lib/modelOptions.test.ts | 105 ++ apps/mobile/src/lib/modelOptions.ts | 77 +- apps/mobile/src/state/thread-outbox-model.ts | 16 + apps/mobile/src/state/thread-outbox.test.ts | 59 + .../src/state/use-thread-outbox-drain.ts | 62 +- apps/server/scripts/acp-mock-agent.ts | 124 +- .../server/src/provider/Drivers/KiloDriver.ts | 175 ++ .../src/provider/Layers/KiloAdapter.test.ts | 1353 ++++++++++++++ .../server/src/provider/Layers/KiloAdapter.ts | 1552 +++++++++++++++++ .../src/provider/Layers/KiloProvider.test.ts | 272 +++ .../src/provider/Layers/KiloProvider.ts | 414 +++++ .../ProviderInstanceRegistryLive.test.ts | 39 +- .../provider/Layers/ProviderRegistry.test.ts | 1 + apps/server/src/provider/ProviderDriver.ts | 2 +- .../src/provider/Services/KiloAdapter.ts | 16 + .../src/provider/acp/AcpSessionRuntime.ts | 128 +- .../src/provider/acp/KiloAcpCliProbe.test.ts | 168 ++ .../src/provider/acp/KiloAcpSupport.test.ts | 409 +++++ .../server/src/provider/acp/KiloAcpSupport.ts | 380 ++++ apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/provider/providerSnapshot.ts | 4 + apps/server/src/serverSettings.ts | 32 +- .../src/textGeneration/TextGeneration.test.ts | 24 +- .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/src/components/Icons.tsx | 10 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/ProviderInstanceCard.tsx | 8 +- .../settings/ProviderModelsSection.tsx | 53 +- .../components/settings/SettingsPanels.tsx | 2 +- .../settings/SourceControlWritingSettings.tsx | 2 +- .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/lib/contextWindow.ts | 2 + apps/web/src/modelSelection.test.ts | 88 + apps/web/src/modelSelection.ts | 42 +- apps/web/src/providerInstances.test.ts | 35 + apps/web/src/providerInstances.ts | 16 +- apps/web/src/session-logic.ts | 6 + docs/internals/glossary.md | 2 +- docs/internals/overview.md | 8 +- docs/internals/providers.md | 37 +- docs/user/install.md | 30 +- docs/user/permission-modes.md | 6 + packages/contracts/src/model.ts | 2 + packages/contracts/src/providerInstance.ts | 13 + packages/contracts/src/server.test.ts | 13 + packages/contracts/src/server.ts | 7 + packages/contracts/src/settings.test.ts | 2 + packages/contracts/src/settings.ts | 38 +- packages/shared/src/serverSettings.test.ts | 57 + packages/shared/src/serverSettings.ts | 97 +- 59 files changed, 5876 insertions(+), 227 deletions(-) create mode 100644 apps/marketing/public/harnesses/kilo-dark.svg create mode 100644 apps/server/src/provider/Drivers/KiloDriver.ts create mode 100644 apps/server/src/provider/Layers/KiloAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/KiloAdapter.ts create mode 100644 apps/server/src/provider/Layers/KiloProvider.test.ts create mode 100644 apps/server/src/provider/Layers/KiloProvider.ts create mode 100644 apps/server/src/provider/Services/KiloAdapter.ts create mode 100644 apps/server/src/provider/acp/KiloAcpCliProbe.test.ts create mode 100644 apps/server/src/provider/acp/KiloAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/KiloAcpSupport.ts diff --git a/AGENTS.md b/AGENTS.md index 784b37cc47b2..ba3f8126f974 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, Kilo Code, OpenCode) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. diff --git a/README.md b/README.md index 8ec101387f67..5e49c046cd9b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Kilo Code, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,12 +13,13 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Kilo Code, and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` +> - Kilo Code: install [Kilo CLI](https://kilo.ai/docs/code-with-ai/platforms/cli) and run `kilo auth login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` ### Try it out (install-free) diff --git a/apps/marketing/public/harnesses/kilo-dark.svg b/apps/marketing/public/harnesses/kilo-dark.svg new file mode 100644 index 000000000000..3af8094b5b4f --- /dev/null +++ b/apps/marketing/public/harnesses/kilo-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index e45cb7602873..9b5cbb061b57 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -49,7 +49,7 @@ const mobileEndorsementRows = [

- Orchestrate Claude Code, Codex, OpenCode, Cursor, and Grok from one surface. + Orchestrate Claude Code, Codex, OpenCode, Cursor, Grok, and Kilo Code from one surface. Bring your own subscription. Fork the whole thing.

@@ -187,7 +187,7 @@ const mobileEndorsementRows = [

Bring your own sub

T3 Code doesn't resell tokens. Plug in Claude Code, Codex, OpenCode, - Cursor, or Grok with the credentials you already have — we orchestrate + Cursor, Grok, or Kilo Code with the credentials you already have — we orchestrate them, you keep your plan.

@@ -228,6 +228,13 @@ const mobileEndorsementRows = [
grok login
+
+
+
+
Kilo Code
+
kilo auth login
+
+
@@ -376,29 +379,33 @@ export function ProviderModelsSection({ })} -
- { - setInput(event.target.value); - if (error) setError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - handleAdd(); - }} - placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} - spellCheck={false} - /> - -
+ {supportsCustomModels ? ( + <> +
+ { + setInput(event.target.value); + if (error) setError(null); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + handleAdd(); + }} + placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} + spellCheck={false} + /> + +
- {error ?

{error}

: null} + {error ?

{error}

: null} + + ) : null} ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..b4a0ec3cad5c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1876,7 +1876,7 @@ export function GeneralSettingsPanel() { const textGenModelOptions = textGenerationModelSelection.options; const textGenerationModelInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), - ); + ).filter((entry) => entry.supportsTextGeneration); const textGenInstanceEntry = textGenerationModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, ); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index d7c094af372b..3b2e98171f34 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -62,7 +62,7 @@ export function SourceControlWritingSettingsSection() { : resolvedSourceControlWriterSelection; const instanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), - ); + ).filter((entry) => entry.supportsTextGeneration); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, serverProviders, diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..8eb4314d3d85 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,20 @@ import { CodexSettings, CursorSettings, GrokSettings, + KiloSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + KiloIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("kilo"), + label: "Kilo Code", + icon: KiloIcon, + badgeLabel: "Early Access", + settingsSchema: KiloSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..cc247de23f74 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -38,6 +38,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; + case "kilo": + return "Kilo Code"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 405366d9fcbe..f2757ebcea1f 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -15,6 +15,7 @@ function provider(input: { provider?: ProviderDriverKind; instanceId: string; models?: ReadonlyArray; + supportsTextGeneration?: boolean; }): ServerProvider { const driver = input.provider ?? @@ -30,6 +31,9 @@ function provider(input: { status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", + ...(input.supportsTextGeneration === undefined + ? {} + : { supportsTextGeneration: input.supportsTextGeneration }), models: (input.models ?? []).map((slug) => ({ slug, name: slug, @@ -176,6 +180,39 @@ describe("instance-scoped model selection", () => { ); }); + it("ignores persisted Kilo custom models outside the authoritative command catalog", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("kilo"), + instanceId: "kilo", + models: ["kilo/live"], + }), + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("kilo")]: { + driver: ProviderDriverKind.make("kilo"), + config: { customModels: ["kilo/stale"] }, + }, + }, + }; + const kilo = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptionsForInstance(settings, kilo).map((option) => option.slug)).toEqual([ + "kilo/live", + ]); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("kilo"), + settings, + providers, + "kilo/stale", + ), + ).toBe("kilo/live"); + }); + it("does not inject an unknown selected slug into the stock instance list", () => { const providers = [ provider({ @@ -323,6 +360,57 @@ describe("instance-scoped model selection", () => { model: "openai/gpt-5.5", }); }); + + it("heals Kilo background selection to a safe custom provider instance", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("kilo"), + instanceId: "kilo", + models: ["__t3_provider_default__", "kilo/openrouter/free"], + supportsTextGeneration: false, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claude_openrouter", + models: ["claude-sonnet-4-6"], + }), + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + textGenerationModelSelection: createModelSelection( + ProviderInstanceId.make("kilo"), + "__t3_provider_default__", + ), + }; + + expect(resolveAppModelSelectionState(settings, providers)).toEqual({ + instanceId: ProviderInstanceId.make("claude_openrouter"), + model: "claude-sonnet-4-6", + }); + }); + + it("returns the no-provider sentinel when only Kilo can run interactive prompts", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("kilo"), + instanceId: "kilo", + models: ["__t3_provider_default__", "kilo/openrouter/free"], + supportsTextGeneration: false, + }), + ]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + textGenerationModelSelection: createModelSelection( + ProviderInstanceId.make("kilo"), + "__t3_provider_default__", + ), + }; + + expect(resolveAppModelSelectionState(settings, providers)).toEqual({ + instanceId: ProviderInstanceId.make("t3code_no_provider"), + model: "", + }); + }); }); describe("withoutPlanAgentSelection", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ccdffdda1004..1607b5a968fb 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -23,7 +23,11 @@ import { resolveSelectableProvider, } from "./providerModels"; import { ModelEsque } from "./components/chat/providerIconUtils"; -import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances"; +import { + NO_PROVIDER_MODEL_SELECTION, + type ProviderInstanceEntry, + deriveProviderInstanceEntries, +} from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -166,7 +170,8 @@ export function getAppModelOptions( // settings and the initial render before the first write both still // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); - const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); + const customModels = + provider === "kilo" ? [] : readInstanceCustomModels(settings, defaultInstanceId, provider); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; @@ -209,7 +214,10 @@ export function getAppModelOptionsForInstance( ), ); - const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); + const customModels = + entry.driverKind === "kilo" + ? [] + : readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; @@ -333,10 +341,17 @@ export function resolveAppModelSelectionState( }; const entries = deriveProviderInstanceEntries(providers); const selectedEntry = entries.find( - (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, + (entry) => + entry.instanceId === selection.instanceId && + entry.enabled && + entry.isAvailable && + entry.supportsTextGeneration, ); const entry = - selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); + selectedEntry ?? + entries.find( + (candidate) => candidate.enabled && candidate.isAvailable && candidate.supportsTextGeneration, + ); if (entry) { // When the instance changed due to fallback (e.g. selected instance was disabled), // don't carry over the old instance's model — use the fallback instance's default. @@ -360,20 +375,5 @@ export function resolveAppModelSelectionState( return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); } - const provider = resolveSelectableProvider(providers, null); - const keptSelectedProvider = false; - - // When the provider changed due to fallback (e.g. selected provider was disabled), - // don't carry over the old provider's model — use the fallback provider's default. - const selectedModel = keptSelectedProvider ? selection.model : null; - const model = resolveAppModelSelection(provider, settings, providers, selectedModel); - const { modelOptionsForDispatch } = getComposerProviderState({ - provider, - model, - models: getProviderModels(providers, provider), - modelOptions: keptSelectedProvider ? selection.options : undefined, - planModeEnabled: settings.planModeEnabled, - }); - - return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch); + return NO_PROVIDER_MODEL_SELECTION; } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index b64a5e25d508..117450cf3a78 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -420,6 +420,41 @@ describe("resolveDefaultProviderModelSelection", () => { expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); }); + it("heals a stale Kilo model to the live command catalog default", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("kilo"), + instanceId: "kilo", + models: [model("kilo/live", false, true), model("kilo/other")], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("kilo"), + model: "kilo/stale", + options: [{ id: "reasoning", value: "high" }], + }), + ).toEqual({ instanceId: "kilo", model: "kilo/live" }); + }); + + it("preserves a Kilo selection while its live catalog is unavailable", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("kilo"), + instanceId: "kilo", + status: "warning", + models: [], + }), + ]; + const stored = { + instanceId: ProviderInstanceId.make("kilo"), + model: "kilo/offline", + }; + + expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); + }); + it("replaces a stale stored instance with the first ready instance and its model", () => { const providers = [ provider({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ef60d554dd78..309ca13c0820 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -16,6 +16,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, + providerSupportsTextGeneration, resolveProviderInstanceEnabled, type ModelSelection, type ProviderDriverKind, @@ -61,6 +62,7 @@ export interface ProviderInstanceEntry { readonly isDefault: boolean; /** True when `availability === "unavailable"` is absent or "available". */ readonly isAvailable: boolean; + readonly supportsTextGeneration: boolean; readonly snapshot: ServerProvider; readonly models: ReadonlyArray; } @@ -194,6 +196,7 @@ export function deriveProviderInstanceEntries( status: snapshot.status, isDefault, isAvailable: snapshot.availability !== "unavailable", + supportsTextGeneration: providerSupportsTextGeneration(snapshot), snapshot, models: snapshot.models, } satisfies ProviderInstanceEntry; @@ -383,7 +386,18 @@ export function resolveDefaultProviderModelSelection( ): ModelSelection | null { const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId); if (instanceId === undefined) return null; - if (selection?.instanceId === instanceId) return selection; + if (selection?.instanceId === instanceId) { + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + if ( + provider?.driver === "kilo" && + provider.models.length > 0 && + !provider.models.some((model) => model.slug === selection.model) + ) { + const model = getDefaultProviderInstanceModel(providers, instanceId); + return model ? { instanceId, model } : null; + } + return selection; + } const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..3124319cf62c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("kilo"), + label: "Kilo Code", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..64fc2ead1ff8 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..61b94d953c1f 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ │ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ Kilo Code, OpenCode │ └────────────────────────────────────────────────┘ ``` @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..1ab0992c6144 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -15,6 +15,7 @@ orchestration layer does not know which one is behind a thread. | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `kilo` | [`Drivers/KiloDriver.ts`][kilo] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an @@ -23,6 +24,37 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. +### Kilo Code ACP constraints + +Kilo Code uses `kilo acp`. Kilo 7.4 starts an internal HTTP listener for each ACP child and tries +port 4096 before falling back to an OS-assigned port. Simultaneous child starts can race on that +first bind, so the Kilo runtime serializes startup within one T3 server; already-started Kilo +sessions remain concurrent. Separate T3 server processes cannot share that in-memory gate and retain +the narrow simultaneous-start race. + +The current Kilo ACP bridge exposes session load/resume, model and mode configuration, images, MCP, +and permissions. It does not forward Kilo question events or expose provider-side rollback. The +adapter therefore leaves follow-up questions unsupported and rejects rollback rather than changing +only T3 Code's local history. + +Kilo plugins and project-declared local MCP processes run outside ACP tool-permission requests. The +adapter forces Kilo pure mode and disables project Kilo config for Supervised, Auto-accept edits, +and Auto sessions so those controls cannot be bypassed during child startup. Full-access sessions +preserve the user's Kilo plugins and project config. + +Provider status and model refreshes run `kilo --version` followed by the official newline-delimited +`kilo models` command. The model command runs with pure mode, project config, external skills, and +skill-shell execution disabled; it does not start ACP, MCP servers, or a durable conversation. The +catalog includes a T3-only “Kilo provider default” sentinel, which the interactive adapter resolves +to the model Kilo reports as current instead of sending the sentinel upstream. Kilo is deliberately +excluded from automatic text generation (thread titles and source-control writing), because those +hidden prompts cannot safely inherit a user's full provider environment. Background-model pickers +therefore offer only provider instances whose snapshot advertises that capability. + +Prompting and the permission/config behavior above are verified against Kilo 7.4.23, which is the +minimum version accepted by the provider status check and the adapter's cached pre-spawn guard. +Kilo's issue tracker records headless ACP prompt hangs in 7.2.24 and 7.3.16.[^kilo-acp-prompt] + ## Registry and routing Two registries separate configuration from live processes: @@ -80,6 +112,7 @@ when a request opens (approval) or user input is requested, via [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[kilo]: ../../apps/server/src/provider/Drivers/KiloDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts @@ -90,3 +123,5 @@ when a request opens (approval) or user input is requested, via [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts + +[^kilo-acp-prompt]: [Kilo issue #10768](https://github.com/Kilo-Org/kilocode/issues/10768) diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..b095fc910d27 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,16 +54,26 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn -them on in **Settings** → the provider's card when you want to use them. +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------------- | -------------- | --------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| Kilo Code | [Kilo CLI](https://kilo.ai/docs/code-with-ai/platforms/cli) | `kilo` | `kilo auth login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | + +Codex, Claude, and Cursor are on by default. Grok Build, Kilo Code, and OpenCode are off by +default; turn them on in **Settings** → the provider's card when you want to use them. + +Kilo Code runs through its ACP bridge and requires Kilo 7.4.23 or newer; use `kilo upgrade` if +Settings reports an older version. Kilo 7.4 does not forward its question tool through ACP, so +follow-up questions are not currently shown in T3 Code. Provider-side conversation rollback is +also unavailable; T3 Code reports rollback as unsupported instead of desynchronizing the two +histories. Settings can verify the Kilo CLI and model catalog with `kilo models` but cannot confirm +login status, so run `kilo auth login` before starting paid-model turns. Kilo is available for +interactive threads but not for automatic text generation such as thread titles or source-control +writing; those settings use another enabled provider instance. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 0648bafc8b77..cd4a09918e89 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -44,4 +44,10 @@ with prompting enabled and a restricted workspace while **Full access** disables labels above describe what you get; the exact per-provider translation is internal and may change. +For Kilo Code, **Supervised**, **Auto-accept edits**, and **Auto** disable Kilo plugins and +project-local Kilo configuration for that session. Kilo can otherwise start repository-declared +plugins or local MCP commands before an approval reaches T3 Code. This also means project-local +Kilo instructions and settings are unavailable in those modes. **Full access** keeps the normal +Kilo extension and project-configuration behavior. + Mobile offers the same four modes with the same labels and descriptions. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..3862588352db 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const KILO_DRIVER_KIND = ProviderDriverKind.make("kilo"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -222,4 +223,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [KILO_DRIVER_KIND]: "Kilo Code", }; diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index 2a9fc9ed0d1b..1a377af06728 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -74,6 +74,19 @@ const isProviderDriverKindValue = Schema.is(ProviderDriverKind); export const isProviderDriverKind = (value: unknown): value is ProviderDriverKind => isProviderDriverKindValue(value); +const PROVIDER_DRIVERS_WITHOUT_TEXT_GENERATION = new Set([ + ProviderDriverKind.make("kilo"), +]); + +/** + * Whether a driver may be used for automatic text generation such as thread + * titles and source-control writing. Unknown/fork drivers remain supported by + * default; a driver is listed here only when its implementation explicitly + * declines the capability. + */ +export const providerDriverSupportsTextGeneration = (driver: ProviderDriverKind): boolean => + !PROVIDER_DRIVERS_WITHOUT_TEXT_GENERATION.has(driver); + /** * `ProviderInstanceId` — user-defined routing key for a configured provider * instance. Same slug rules as `ProviderDriverKind`; branded separately so the diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c4..6532c6d6e318 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -6,6 +6,7 @@ import { ServerProvider, ServerProviders, ServerUpsertKeybindingResult, + providerSupportsTextGeneration, } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); @@ -45,6 +46,18 @@ describe("ServerProvider", () => { expect(parsed.skills).toEqual([]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); + expect(providerSupportsTextGeneration(parsed)).toBe(true); + }); + + it("decodes an explicit automatic text-generation opt-out", () => { + const parsed = decodeServerProvider({ + ...baseProviderSnapshot, + driver: "kilo", + instanceId: "kilo", + supportsTextGeneration: false, + }); + + expect(providerSupportsTextGeneration(parsed)).toBe(false); }); it("defaults one-click update support when decoding older advisory snapshots", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..dbaefd4b0452 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -171,6 +171,9 @@ export const ServerProvider = Schema.Struct({ continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), + // Optional for wire compatibility. Absence means supported; drivers that + // cannot safely run automatic background prompts set this to false. + supportsTextGeneration: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), @@ -213,6 +216,10 @@ export type ServerProviders = typeof ServerProviders.Type; export const isProviderAvailable = (snapshot: ServerProvider): boolean => snapshot.availability !== "unavailable"; +/** Legacy snapshots predate this capability flag and remain supported. */ +export const providerSupportsTextGeneration = (snapshot: ServerProvider): boolean => + snapshot.supportsTextGeneration !== false; + export const ServerObservability = Schema.Struct({ logsDirectoryPath: TrimmedNonEmptyString, localTracingEnabled: Schema.Boolean, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..6718fceb229f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -202,6 +202,7 @@ describe("provider enabled defaults", () => { expect(decoded.providers.claudeAgent.enabled).toBe(true); expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); + expect(decoded.providers.kilo.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); }); @@ -209,6 +210,7 @@ describe("provider enabled defaults", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("kilo"))).toBe(false); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..78024886eb85 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -455,7 +455,7 @@ export type CursorSettings = typeof CursorSettings.Type; export const GrokSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and OpenCode): the binding is not yet + // Off by default (like Kilo and OpenCode): the binding is not yet // stable enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), @@ -481,7 +481,7 @@ export type GrokSettings = typeof GrokSettings.Type; export const OpenCodeSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and Grok): the binding is not yet stable + // Off by default (like Grok and Kilo): the binding is not yet stable // enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), @@ -531,6 +531,32 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const KiloSettings = makeProviderSettingsSchema( + { + // Off by default (like Grok and OpenCode): the binding is not yet stable + // enough to probe on every install. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kilo").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kilo CLI binary.", + providerSettingsForm: { placeholder: "kilo", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type KiloSettings = typeof KiloSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -673,6 +699,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + kilo: KiloSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -820,6 +847,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const KiloSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -861,6 +894,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + kilo: Schema.optionalKey(KiloSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index baa84a4e1aa8..c28cfb56fdc1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import { isModelSelectionProviderEnabled, normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, + resolveTextGenerationModelSelection, resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; @@ -264,6 +265,62 @@ describe("serverSettings helpers", () => { expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); }); + it("heals unsupported Kilo background selection to an enabled custom instance", () => { + const safeInstanceId = ProviderInstanceId.make("claude_personal"); + const kiloSelection = createModelSelection(ProviderInstanceId.make("kilo"), "provider/model"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, enabled: false }, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false }, + cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: false }, + grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: false }, + opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: false }, + kilo: { ...DEFAULT_SERVER_SETTINGS.providers.kilo, enabled: true }, + }, + providerInstances: { + [safeInstanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: kiloSelection, + }; + + expect(resolveTextGenerationModelSelection(settings)).toEqual({ + instanceId: safeInstanceId, + model: "claude-haiku-4-5", + }); + }); + + it("does not resurrect a disabled explicit default instance from its legacy mirror", () => { + const kiloSelection = createModelSelection(ProviderInstanceId.make("kilo"), "provider/model"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, enabled: true }, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false }, + cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: false }, + grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: false }, + opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: false }, + kilo: { ...DEFAULT_SERVER_SETTINGS.providers.kilo, enabled: true }, + }, + providerInstances: { + codex: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + }, + textGenerationModelSelection: kiloSelection, + }; + + expect(resolveTextGenerationModelSelection(settings)).toBe(kiloSelection); + }); + it("replaces providerInstances maps so omitted instance fields are cleared", () => { const codexId = ProviderInstanceId.make("codex"); const current = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 69fc9eaacbcc..49cdcd7dc3a2 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,9 +1,15 @@ import { + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, isProviderDriverKind, isProviderAvailable, + providerDriverSupportsTextGeneration, + providerSupportsTextGeneration, resolveProviderInstanceEnabled, type ModelSelection, - type ProviderDriverKind, + ProviderDriverKind, + ProviderInstanceId, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -46,22 +52,103 @@ export function isModelSelectionProviderEnabled( ); } +function resolveModelSelectionDriver( + settings: ServerSettings, + selection: ModelSelection, +): ProviderDriverKind | undefined { + const instance = settings.providerInstances[selection.instanceId]; + if (instance !== undefined) return instance.driver; + return isProviderDriverKind(selection.instanceId) && + getLegacyProviderSettings(settings, selection.instanceId) !== undefined + ? selection.instanceId + : undefined; +} + +export function isModelSelectionTextGenerationSupported( + settings: ServerSettings, + selection: ModelSelection, +): boolean { + const driver = resolveModelSelectionDriver(settings, selection); + return driver !== undefined && providerDriverSupportsTextGeneration(driver); +} + +function defaultTextGenerationSelection( + instanceId: ModelSelection["instanceId"], + driver: ProviderDriverKind, +): ModelSelection { + return createModelSelection( + instanceId, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL, + ); +} + +/** + * Find an actually enabled text-generation-capable instance. Explicit default + * instances shadow their legacy mirror even when disabled, while enabled + * custom instances remain eligible. + */ +export function findEnabledTextGenerationFallback( + settings: ServerSettings, +): ModelSelection | undefined { + for (const [rawInstanceId, instance] of Object.entries(settings.providerInstances)) { + if ( + resolveProviderInstanceEnabled(instance) && + providerDriverSupportsTextGeneration(instance.driver) + ) { + return defaultTextGenerationSelection( + ProviderInstanceId.make(rawInstanceId), + instance.driver, + ); + } + } + + for (const [rawDriver, provider] of Object.entries(settings.providers)) { + const driver = ProviderDriverKind.make(rawDriver); + if ( + settings.providerInstances[ProviderInstanceId.make(rawDriver)] !== undefined || + !provider.enabled || + !providerDriverSupportsTextGeneration(driver) + ) { + continue; + } + return defaultTextGenerationSelection(ProviderInstanceId.make(rawDriver), driver); + } + return undefined; +} + +export function resolveTextGenerationModelSelection(settings: ServerSettings): ModelSelection { + const selection = settings.textGenerationModelSelection; + return isModelSelectionProviderEnabled(settings, selection) && + isModelSelectionTextGenerationSupported(settings, selection) + ? selection + : (findEnabledTextGenerationFallback(settings) ?? selection); +} + export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers?: ReadonlyArray, ): ModelSelection { + const defaultSelection = resolveTextGenerationModelSelection(settings); const selection = settings.sourceControlWriterModelSelection; - if (!selection || !isModelSelectionProviderEnabled(settings, selection)) { - return settings.textGenerationModelSelection; + if ( + !selection || + !isModelSelectionProviderEnabled(settings, selection) || + !isModelSelectionTextGenerationSupported(settings, selection) + ) { + return defaultSelection; } if (providers === undefined) { return selection; } const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); - return provider?.enabled === true && isProviderAvailable(provider) + return provider?.enabled === true && + isProviderAvailable(provider) && + providerSupportsTextGeneration(provider) ? selection - : settings.textGenerationModelSelection; + : defaultSelection; } export interface PersistedServerObservabilitySettings { From c1a58428b25982983ba638468b8841dad9acee72 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 22:21:51 -0400 Subject: [PATCH 2/8] fix(providers): harden Kilo Code integration --- apps/marketing/src/pages/index.astro | 21 +- apps/server/scripts/acp-mock-agent.ts | 27 +- .../src/provider/Drivers/KiloDriver.test.ts | 17 + .../server/src/provider/Drivers/KiloDriver.ts | 8 +- .../src/provider/Layers/KiloAdapter.test.ts | 650 +++++++++- .../server/src/provider/Layers/KiloAdapter.ts | 1042 ++++++++++------- .../provider/acp/AcpJsonRpcConnection.test.ts | 76 ++ .../src/provider/acp/AcpSessionRuntime.ts | 47 +- .../src/provider/acp/KiloAcpSupport.test.ts | 54 +- .../server/src/provider/acp/KiloAcpSupport.ts | 70 +- .../settings/ProviderSettingsPanel.tsx | 8 +- .../components/settings/SettingsPanels.tsx | 132 ++- ...SourceControlWritingSettings.logic.test.ts | 31 + .../SourceControlWritingSettings.logic.ts | 21 + .../settings/SourceControlWritingSettings.tsx | 45 +- apps/web/src/modelSelection.test.ts | 28 + apps/web/src/modelSelection.ts | 7 + apps/web/src/providerInstances.ts | 4 + docs/internals/providers.md | 10 +- 19 files changed, 1793 insertions(+), 505 deletions(-) create mode 100644 apps/server/src/provider/Drivers/KiloDriver.test.ts create mode 100644 apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/SourceControlWritingSettings.logic.ts diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 9b5cbb061b57..9ccd59496e7c 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -818,7 +818,7 @@ const mobileEndorsementRows = [ .harness-grid { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 0; border: 1px solid var(--border); border-radius: var(--radius); @@ -829,10 +829,12 @@ const mobileEndorsementRows = [ padding: 22px 20px; display: flex; align-items: center; gap: 14px; border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent); transition: background 0.2s ease; } - .harness:last-child { border-right: 0; } + .harness:nth-child(3n) { border-right: 0; } + .harness:nth-child(n + 4) { border-bottom: 0; } .harness:hover { background: rgba(255, 255, 255, 0.025); } .harness-mark { @@ -1273,10 +1275,7 @@ const mobileEndorsementRows = [ .harness-grid { grid-template-columns: 1fr 1fr; } .harness { border-right: 0; border-bottom: 1px solid var(--border); } .harness:nth-child(odd) { border-right: 1px solid var(--border); } - .harness:nth-child(5):last-child { - grid-column: 1 / -1; - border-right: 0; - } + .harness:nth-child(n + 5) { border-bottom: 0; } .git-inner { grid-template-columns: 1fr; gap: 40px; } .open-grid { grid-template-columns: 1fr; } } @@ -1314,4 +1313,14 @@ const mobileEndorsementRows = [ font-size: 13px; } } + + @media (max-width: 520px) { + .harness-grid { grid-template-columns: 1fr; } + .harness, + .harness:nth-child(odd) { + border-right: 0; + border-bottom: 1px solid var(--border); + } + .harness:last-child { border-bottom: 0; } + } diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index adfd55338a4c..cd92ae2e8e2d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -2,6 +2,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -29,6 +30,7 @@ const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; const emitPromptStartedBeforeHang = process.env.T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG === "1"; const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; +const ignorePromptCancelSettlement = process.env.T3_ACP_IGNORE_PROMPT_CANCEL_SETTLEMENT === "1"; const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; @@ -42,6 +44,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const hangSetConfigOption = process.env.T3_ACP_HANG_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); @@ -65,6 +68,7 @@ let promptCount = 0; let setConfigCount = 0; let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); +const promptCancelSignals = new Map>(); if (environmentLogPath) { NodeFS.writeFileSync( @@ -449,6 +453,9 @@ const program = Effect.gen(function* () { yield* agent.handleSetSessionConfigOption((request) => Effect.gen(function* () { setConfigCount += 1; + if (hangSetConfigOption) { + return yield* Effect.never; + } if ( setConfigCount > setConfigDelayAfter && Number.isFinite(setConfigDelayMs) && @@ -499,7 +506,6 @@ const program = Effect.gen(function* () { const cancelledSessionId = String(sessionId ?? "mock-session-1"); cancelledSessions.add(cancelledSessionId); if (emitLateUpdateAfterCancel) { - yield* Effect.sleep("50 millis"); yield* Effect.sync(() => { writeJsonRpcNotification("session/update", { sessionId: cancelledSessionId, @@ -510,6 +516,10 @@ const program = Effect.gen(function* () { }); }); } + const promptCancelSignal = promptCancelSignals.get(cancelledSessionId); + if (promptCancelSignal && !ignorePromptCancelSettlement) { + yield* Deferred.succeed(promptCancelSignal, undefined); + } }), ); @@ -580,6 +590,11 @@ const program = Effect.gen(function* () { } if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { + if (cancelledSessions.delete(requestedSessionId)) { + return { stopReason: "cancelled" as const }; + } + const promptCancelSignal = yield* Deferred.make(); + promptCancelSignals.set(requestedSessionId, promptCancelSignal); if (emitPromptStartedBeforeHang) { yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, @@ -589,7 +604,15 @@ const program = Effect.gen(function* () { }, }); } - return yield* Effect.never; + yield* Deferred.await(promptCancelSignal).pipe( + Effect.ensuring( + Effect.sync(() => { + promptCancelSignals.delete(requestedSessionId); + cancelledSessions.delete(requestedSessionId); + }), + ), + ); + return { stopReason: "cancelled" as const }; } if (emitXAiPromptCompleteThenHang) { diff --git a/apps/server/src/provider/Drivers/KiloDriver.test.ts b/apps/server/src/provider/Drivers/KiloDriver.test.ts new file mode 100644 index 000000000000..fa52429c1ba2 --- /dev/null +++ b/apps/server/src/provider/Drivers/KiloDriver.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isKiloNativeCommandPath } from "./KiloDriver.ts"; + +describe("KiloDriver maintenance path classification", () => { + it("recognizes only the standalone Kilo installer directory", () => { + expect(isKiloNativeCommandPath("/Users/dev/.kilo/bin/kilo")).toBe(true); + expect(isKiloNativeCommandPath("C:\\Users\\dev\\.kilo\\bin\\kilo.exe")).toBe(true); + expect(isKiloNativeCommandPath("/Users/dev/.kilocode/bin/kilo")).toBe(false); + }); + + it("keeps package-manager bin directories on package-managed updates", () => { + expect(isKiloNativeCommandPath("/usr/local/bin/kilo")).toBe(false); + expect(isKiloNativeCommandPath("/opt/homebrew/bin/kilo")).toBe(false); + expect(isKiloNativeCommandPath("C:\\Users\\dev\\AppData\\Roaming\\npm\\kilo.cmd")).toBe(false); + }); +}); diff --git a/apps/server/src/provider/Drivers/KiloDriver.ts b/apps/server/src/provider/Drivers/KiloDriver.ts index 75f2c79d8243..bdc8b65664c0 100644 --- a/apps/server/src/provider/Drivers/KiloDriver.ts +++ b/apps/server/src/provider/Drivers/KiloDriver.ts @@ -40,13 +40,9 @@ const decodeKiloSettings = Schema.decodeSync(KiloSettings); const DRIVER_KIND = ProviderDriverKind.make("kilo"); -function isKiloNativeCommandPath(commandPath: string): boolean { +export function isKiloNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); - return ( - normalized.endsWith("/.kilocode/bin/kilo") || - normalized.endsWith("/.kilocode/bin/kilo.exe") || - normalized.endsWith("/bin/kilo") - ); + return normalized.endsWith("/.kilo/bin/kilo") || normalized.endsWith("/.kilo/bin/kilo.exe"); } const UPDATE = makePackageManagedProviderMaintenanceResolver({ diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index befbc2249ac6..b7c096eda5fc 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -19,6 +19,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; import { ApprovalRequestId, @@ -32,10 +33,17 @@ import { import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { KILO_PROVIDER_DEFAULT_MODEL_ID } from "../acp/KiloAcpSupport.ts"; -import { makeKiloAdapter, makeKiloThreadLockRegistry } from "./KiloAdapter.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { KILO_PROVIDER_DEFAULT_MODEL_ID, startKiloAcpRuntime } from "../acp/KiloAcpSupport.ts"; +import { + makeKiloAdapter, + makeKiloThreadLockRegistry, + resolveKiloRequestedModeId, +} from "./KiloAdapter.ts"; const decodeKiloSettings = Schema.decodeSync(KiloSettings); const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +const isAcpTransportError = Schema.is(EffectAcpErrors.AcpTransportError); const decodeKiloEnvironmentLog = Schema.decodeUnknownSync( Schema.fromJsonString( Schema.Struct({ @@ -173,6 +181,36 @@ const runPermissionScenario = (input: { }; }); +it.effect("does not treat explanation as a plan-mode alias", () => + Effect.sync(() => { + const explanationOnly = { + currentModeId: "explanation", + availableModes: [{ id: "explanation", name: "Explanation" }], + }; + assert.isUndefined( + resolveKiloRequestedModeId({ + interactionMode: "plan", + runtimeMode: "approval-required", + modeState: explanationOnly, + }), + ); + assert.equal( + resolveKiloRequestedModeId({ + interactionMode: "plan", + runtimeMode: "approval-required", + modeState: { + currentModeId: "explanation", + availableModes: [ + ...explanationOnly.availableModes, + { id: "safe-design", name: "Safe design", description: "Use plan for this turn" }, + ], + }, + }), + "safe-design", + ); + }), +); + it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { it.effect("isolates non-full interactive children while preserving full-access extensions", () => Effect.gen(function* () { @@ -808,6 +846,163 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("does not register a permission after its turn was interrupted during logging", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-permission-registration-vs-interrupt"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const permissionLoggingStarted = yield* Deferred.make(); + const releasePermissionLogging = yield* Deferred.make(); + const cancellationWaiting = yield* Deferred.make(); + const respondToRequests = yield* Ref.make(false); + const secondRequestOpened = yield* Deferred.make(); + const cancellationAwareStarter: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime( + { + ...input, + beforeCancelSettlementWait: Deferred.succeed(cancellationWaiting, undefined), + }, + configureRuntime, + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime: cancellationAwareStarter, + nativeEventLogger: { + filePath: "memory://kilo-permission-registration-race", + write: (record: unknown) => { + const encoded = JSON.stringify(record); + return encoded.includes('"kind":"notification"') && + encoded.includes('"method":"session/request_permission"') + ? Deferred.succeed(permissionLoggingStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePermissionLogging)), + ) + : Effect.void; + }, + close: () => Effect.void, + }, + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "request.opened" + ? Ref.get(respondToRequests).pipe( + Effect.flatMap((shouldRespond) => + shouldRespond + ? Deferred.succeed( + secondRequestOpened, + ApprovalRequestId.make(String(event.requestId)), + ).pipe( + Effect.andThen( + adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "decline", + ), + ), + ) + : Effect.void, + ), + ) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const firstTurn = yield* adapter + .sendTurn({ threadId, input: "interrupt before approval registration", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(permissionLoggingStarted); + const interrupt = yield* adapter + .interruptTurn(threadId) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cancellationWaiting); + yield* Deferred.succeed(releasePermissionLogging, undefined); + assert.isTrue(Exit.isSuccess(yield* Fiber.join(interrupt))); + yield* Fiber.join(firstTurn); + + assert.equal(runtimeEvents.filter((event) => event.type === "request.opened").length, 0); + assert.equal(runtimeEvents.filter((event) => event.type === "request.resolved").length, 0); + + yield* Ref.set(respondToRequests, true); + const secondTurn = yield* adapter + .sendTurn({ threadId, input: "permission after cancelled turn", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondRequestOpened); + yield* Fiber.join(secondTurn); + assert.equal(runtimeEvents.filter((event) => event.type === "request.opened").length, 1); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes a claimed approval even when its responder is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-claimed-approval-interruption"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const approvalClaimed = yield* Deferred.make(); + const releaseApproval = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + afterApprovalClaim: () => + Deferred.succeed(approvalClaimed, undefined).pipe( + Effect.andThen(Deferred.await(releaseApproval)), + ), + }); + const opened = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "request.opened" + ? Deferred.succeed(opened, ApprovalRequestId.make(String(event.requestId))).pipe( + Effect.ignore, + ) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const firstTurn = yield* adapter + .sendTurn({ threadId, input: "claim approval", attachments: [] }) + .pipe(Effect.forkChild); + const requestId = yield* Deferred.await(opened); + const response = yield* adapter + .respondToRequest(threadId, requestId, "acceptForSession") + .pipe(Effect.forkChild); + yield* Deferred.await(approvalClaimed); + const interruptResponse = yield* Fiber.interrupt(response).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseApproval, undefined); + yield* Fiber.join(interruptResponse); + yield* Fiber.join(firstTurn); + + const duplicate = yield* adapter + .respondToRequest(threadId, requestId, "decline") + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(duplicate)); + yield* adapter.sendTurn({ threadId, input: "same permission", attachments: [] }); + assert.equal(runtimeEvents.filter((event) => event.type === "request.opened").length, 1); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("cancel wins over a delayed accept-for-session response", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-cancel-approval-race"); @@ -869,6 +1064,61 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("serializes an approval claim with interrupt cancellation", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-approval-claim-vs-interrupt"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const approvalClaimed = yield* Deferred.make(); + const releaseApproval = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + afterApprovalClaim: () => + Deferred.succeed(approvalClaimed, undefined).pipe( + Effect.andThen(Deferred.await(releaseApproval)), + ), + }); + const opened = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Deferred.succeed(opened, ApprovalRequestId.make(String(event.requestId))).pipe( + Effect.ignore, + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter + .sendTurn({ threadId, input: "claim before interrupt", attachments: [] }) + .pipe(Effect.forkChild); + const requestId = yield* Deferred.await(opened); + const response = yield* adapter + .respondToRequest(threadId, requestId, "acceptForSession") + .pipe(Effect.forkChild); + yield* Deferred.await(approvalClaimed); + const interrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseApproval, undefined); + yield* Fiber.join(response); + yield* Fiber.join(interrupt); + yield* Fiber.join(turn); + + const duplicate = yield* adapter + .respondToRequest(threadId, requestId, "decline") + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(duplicate)); + yield* adapter.sendTurn({ threadId, input: "same permission", attachments: [] }); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("reject wins over a duplicate accept-for-session response", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-reject-approval-race"); @@ -1014,6 +1264,265 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("quarantines the session when ACP cancellation cannot be sent", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-cancel-transport-failure"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-cancel-failure-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const failingCancelStarter: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime(input).pipe( + Effect.flatMap(({ runtime, started }) => { + const wrappedRuntime = { + ...runtime, + cancel: Effect.fail( + new EffectAcpErrors.AcpTransportError({ + detail: "Mock cancellation transport failure.", + cause: new Error("cancel transport closed"), + }), + ), + }; + return (configureRuntime?.(wrappedRuntime) ?? Effect.void).pipe( + Effect.as({ runtime: wrappedRuntime, started }), + ); + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime: failingCancelStarter, + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "keep running on failed cancel", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const interruptExit = yield* adapter.interruptTurn(threadId).pipe(Effect.exit); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isFailure(interruptExit)); + if (Exit.isFailure(interruptExit)) { + const failure = Cause.squash(interruptExit.cause); + assert.isTrue(isProviderAdapterRequestError(failure)); + if (isProviderAdapterRequestError(failure)) { + assert.equal(failure.method, "session/cancel"); + assert.isTrue(isAcpTransportError(failure.cause)); + } + } + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.isFalse(yield* adapter.hasSession(threadId)); + const completed = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.equal(completed.length, 1); + if (completed[0]?.type === "turn.completed") { + assert.equal(completed[0].payload.state, "failed"); + } + const exited = runtimeEvents.find((event) => event.type === "session.exited"); + assert.equal(exited?.type === "session.exited" && exited.payload.exitKind, "error"); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + + const nextPrompt = yield* adapter + .sendTurn({ threadId, input: "must not reuse uncertain child", attachments: [] }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(nextPrompt)); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("quarantines the session when the remote prompt never settles after cancellation", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-cancel-settlement-timeout"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-cancel-timeout-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const cancellationWaiting = yield* Deferred.make(); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + T3_ACP_IGNORE_PROMPT_CANCEL_SETTLEMENT: "1", + }), + ); + const boundedCancelStarter: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime( + { + ...input, + cancelSettleTimeout: "1 second", + beforeCancelSettlementWait: Deferred.succeed(cancellationWaiting, undefined), + }, + configureRuntime, + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime: boundedCancelStarter, + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "remote ignores cancellation", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const interrupt = yield* adapter + .interruptTurn(threadId) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cancellationWaiting); + yield* TestClock.adjust("1100 millis"); + const interruptExit = yield* Fiber.join(interrupt); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isFailure(interruptExit)); + if (Exit.isFailure(interruptExit)) { + const failure = Cause.squash(interruptExit.cause); + assert.isTrue(isProviderAdapterRequestError(failure)); + if (isProviderAdapterRequestError(failure)) { + assert.equal(failure.method, "session/cancel"); + assert.isTrue(isAcpTransportError(failure.cause)); + } + } + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.isFalse(yield* adapter.hasSession(threadId)); + const completed = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.equal(completed.length, 1); + if (completed[0]?.type === "turn.completed") { + assert.equal(completed[0].payload.state, "failed"); + } + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + + const nextPrompt = yield* adapter + .sendTurn({ threadId, input: "must require a new session", attachments: [] }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(nextPrompt)); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("drops late ACP output after a cancelled turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-drop-late-cancelled-output"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", + }), + ); + const lateNativeUpdate = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "memory://kilo-cancelled-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("late after cancel") + ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const promptStarted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "cancel before stale output", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(promptStarted); + yield* adapter.interruptTurn(threadId); + // Kilo acknowledges cancellation by settling session/prompt. The + // adapter must not release the turn for replacement until output queued + // before that acknowledgement has crossed its event barrier. + assert.isTrue(yield* Deferred.isDone(lateNativeUpdate)); + const cancelled = yield* Fiber.join(send); + const replacement = yield* adapter.sendTurn({ + threadId, + input: "replacement after cancelled prompt", + attachments: [], + }); + for (let attempt = 0; attempt < 8; attempt += 1) yield* Effect.yieldNow; + + const cancelledIndex = runtimeEvents.findIndex( + (event) => event.type === "turn.completed" && event.payload.state === "cancelled", + ); + const outputTypes = new Set([ + "content.delta", + "item.started", + "item.updated", + "item.completed", + "turn.plan.updated", + ]); + assert.isAtLeast(cancelledIndex, 0); + assert.notEqual(cancelled.turnId, replacement.turnId); + assert.deepEqual( + runtimeEvents + .slice(cancelledIndex + 1) + .filter((event) => event.turnId === cancelled.turnId && outputTypes.has(event.type)), + [], + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("settles a prompt failure exactly once and restores the session to ready", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-prompt-failure"); @@ -1241,6 +1750,87 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("stopAll cancels a session startup already in progress", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-all-during-start"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-stop-all-start-")), + ); + const markerPath = NodePath.join(tempDir, "startup-config-started"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_SET_CONFIG_DELAY_MS: "250", + T3_ACP_SET_CONFIG_DELAY_AFTER: "0", + T3_ACP_SET_CONFIG_MARKER_PATH: markerPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + const starting = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.exit, Effect.forkChild); + yield* waitForPathCreation(markerPath); + yield* adapter.stopAll(); + const startExit = yield* Fiber.join(starting); + + assert.isTrue(Exit.isFailure(startExit)); + if (Exit.isFailure(startExit)) { + assert.include(String(Cause.squash(startExit.cause)), "startup was cancelled"); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.equal(runtimeEvents.filter((event) => event.type === "session.started").length, 0); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 0); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("serializes stopSession with stopAll and emits one terminal session event", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-all-stop-session-race"); + const wrapperPath = yield* Effect.promise(() => makeMockKiloWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sessionExited = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* Effect.all( + [adapter.stopSession(threadId).pipe(Effect.exit), adapter.stopAll().pipe(Effect.exit)], + { + concurrency: "unbounded", + }, + ); + yield* Deferred.await(sessionExited); + + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.isFalse(yield* adapter.hasSession(threadId)); + yield* Fiber.interrupt(eventsFiber); + }), + ); + it.effect("force-kills the exact scoped child when it ignores SIGTERM", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-force-kill-session-close"); @@ -1319,7 +1909,7 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { yield* TestClock.adjust("60 millis"); const error = yield* Fiber.join(firstStart); assert.equal(error._tag, "ProviderAdapterProcessError"); - assert.include(error.message, "did not become ready"); + assert.include(error.message, "did not complete startup and initial configuration"); assert.equal(yield* Ref.get(killCount), 1); const session = yield* adapter.startSession({ @@ -1333,6 +1923,60 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("bounds initial configuration and releases concurrent and subsequent stopAll", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stuck-initial-configuration"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-initial-config-timeout-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const initialConfigRequested = yield* Deferred.make(); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_SET_CONFIG_OPTION: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startupTimeout: "1 second", + nativeEventLogger: { + filePath: "memory://kilo-initial-config-timeout", + write: (record: unknown) => + JSON.stringify(record).includes("session/set_config_option") + ? Deferred.succeed(initialConfigRequested, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + + const starting = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initialConfigRequested); + const concurrentStopAll = yield* adapter + .stopAll() + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1100 millis"); + + const error = yield* Fiber.join(starting); + const concurrentStopExit = yield* Fiber.join(concurrentStopAll); + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.include(error.message, "did not complete startup and initial configuration"); + assert.isTrue(Exit.isSuccess(concurrentStopExit)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + + yield* adapter.stopAll(); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + }), + ); + it.effect("rejects rollback instead of desynchronizing the ACP session", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-rollback-unsupported"); diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index 867c322803b1..554e70f885fe 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -203,10 +203,14 @@ export interface KiloAdapterLiveOptions { * that reads the latest snapshot so the closure isn't stale. */ readonly resolveSettings?: Effect.Effect; - /** Overrides the Kilo ACP readiness deadline. Primarily used by focused tests. */ + /** Overrides the Kilo ACP readiness and initial-configuration deadline. */ readonly startupTimeout?: Duration.Input; /** Injects the keyed lifecycle lock registry for deterministic concurrency tests. */ readonly threadLockRegistry?: KiloThreadLockRegistry; + /** Optional synchronization hook after an approval is claimed. Used by focused race tests. */ + readonly afterApprovalClaim?: (requestId: ApprovalRequestId) => Effect.Effect; + /** Overrides ACP runtime startup. Used by focused adapter-boundary tests. */ + readonly startAcpRuntime?: typeof startKiloAcpRuntime; } interface PendingApproval { @@ -308,18 +312,20 @@ export const makeKiloThreadLockRegistry: Effect.Effect = function settlePendingApprovalsAsCancelled( pendingApprovals: Map, ): Effect.Effect { - return Effect.suspend(() => { - const pendingEntries = Array.from(pendingApprovals.values()); - // Claim all requests before the first cooperative yield. A response from - // another device after this point is stale and cannot populate the - // session approval cache after cancellation. - pendingApprovals.clear(); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { discard: true }, - ); - }); + return Effect.uninterruptible( + Effect.suspend(() => { + const pendingEntries = Array.from(pendingApprovals.values()); + // Claim all requests before the first cooperative yield. A response from + // another device after this point is stale and cannot populate the + // session approval cache after cancellation. + pendingApprovals.clear(); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + }), + ); } const KiloResumeCursor = Schema.Struct({ @@ -360,9 +366,11 @@ function findModeByAliases( } } for (const alias of normalizedAliases) { - const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); - if (partial) { - return partial; + const wholeWord = modes.find((mode) => + normalizeModeSearchText(mode).split(" ").includes(alias), + ); + if (wholeWord) { + return wholeWord; } } return undefined; @@ -376,7 +384,7 @@ function isReadOnlyMode(mode: AcpSessionMode): boolean { return findModeByAliases([mode], ACP_READ_ONLY_MODE_ALIASES) !== undefined; } -function resolveRequestedModeId(input: { +export function resolveKiloRequestedModeId(input: { readonly interactionMode: ProviderInteractionMode | undefined; readonly runtimeMode: RuntimeMode; readonly modeState: AcpSessionModeState | undefined; @@ -445,9 +453,13 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const startAcpRuntime = options?.startAcpRuntime ?? startKiloAcpRuntime; const sessions = new Map(); const threadLocks = options?.threadLockRegistry ?? (yield* makeKiloThreadLockRegistry); + const startingSessionCounts = new Map(); + let stopAllGeneration = 0; + let stopAllInProgressCount = 0; const verifiedVersionCommands = new Set(); const inFlightVersionChecks = new Map>(); const versionCheckSemaphore = yield* Semaphore.make(1); @@ -582,6 +594,40 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( const withThreadLock = (threadId: string, effect: Effect.Effect) => threadLocks.withLock(threadId, effect); + const registerSessionStart = Effect.fn("registerSessionStart")(function* (threadId: ThreadId) { + if (stopAllInProgressCount > 0) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Kilo sessions are stopping; retry after shutdown finishes.", + }); + } + startingSessionCounts.set(threadId, (startingSessionCounts.get(threadId) ?? 0) + 1); + return stopAllGeneration; + }); + + const unregisterSessionStart = (threadId: ThreadId) => + Effect.sync(() => { + const count = startingSessionCounts.get(threadId); + if (count === undefined || count <= 1) { + startingSessionCounts.delete(threadId); + } else { + startingSessionCounts.set(threadId, count - 1); + } + }); + + const ensureSessionStartIsCurrent = Effect.fn("ensureSessionStartIsCurrent")(function* ( + threadId: ThreadId, + generation: number, + ) { + if (generation === stopAllGeneration && stopAllInProgressCount === 0) return; + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Kilo session startup was cancelled because all provider sessions are stopping.", + }); + }); + const logNative = Effect.fn("logNative")(function* ( threadId: ThreadId, method: string, @@ -608,6 +654,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( const emitPlanUpdate = Effect.fn("emitPlanUpdate")(function* ( ctx: KiloSessionContext, + turnId: TurnId, payload: { readonly explanation?: string | null; readonly plan: ReadonlyArray<{ @@ -618,7 +665,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( rawPayload: unknown, method: string, ) { - const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + const fingerprint = `${turnId}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; if (ctx.lastPlanFingerprint === fingerprint) { return; } @@ -628,7 +675,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, - turnId: ctx.activeTurnId, + turnId, payload, source: "acp.jsonrpc", method, @@ -685,6 +732,48 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( }); }); + const quarantineSessionInternal = Effect.fn("quarantineSessionInternal")(function* ( + ctx: KiloSessionContext, + errorMessage: string, + ) { + if (ctx.stopped) return; + const stoppedTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + const shouldSettleTurn = + stoppedTurnId !== undefined && + (ctx.session.status === "running" || ctx.session.status === "connecting"); + + // Make every local path observe the quarantine before closing the runtime. + // Cancellation uncertainty means queued remote work must never reuse this + // child or transition the session back to ready. + ctx.stopped = true; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + + if (shouldSettleTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: stoppedTurnId, + payload: { state: "failed", errorMessage }, + }); + } + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "error" }, + }); + }); + const applyRequestedSessionConfiguration = Effect.fn("applyRequestedSessionConfiguration")( function* (input: { readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; @@ -702,7 +791,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), }); - const requestedModeId = resolveRequestedModeId({ + const requestedModeId = resolveKiloRequestedModeId({ interactionMode: input.interactionMode, runtimeMode: input.runtimeMode, modeState: yield* input.runtime.getModeState, @@ -737,370 +826,444 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( ); const startSession: KiloAdapterShape["startSession"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - if (input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - if (!input.cwd?.trim()) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "cwd is required and must be non-empty.", - }); - } + Effect.acquireUseRelease( + registerSessionStart(input.threadId), + (startGeneration) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + yield* ensureSessionStartIsCurrent(input.threadId, startGeneration); + if (input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } - const cwd = path.resolve(input.cwd.trim()); - const kiloModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } + const cwd = path.resolve(input.cwd.trim()); + const kiloModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } - const pendingApprovals = new Map(); - const sessionApprovedPermissionFingerprints = new Set(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); - let ctx!: KiloSessionContext; + const pendingApprovals = new Map(); + const sessionApprovedPermissionFingerprints = new Set(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: KiloSessionContext; - const resumeSessionId = parseKiloResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, - provider: PROVIDER, - threadId: input.threadId, - }); + const resumeSessionId = parseKiloResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); - const effectiveKiloSettings = options?.resolveSettings - ? yield* options.resolveSettings - : kiloSettings; - yield* ensureSupportedKiloVersion(effectiveKiloSettings, input.threadId); - const baseEnvironment = enforceKiloInteractiveModeEnvironment( - options?.environment ?? process.env, - input.runtimeMode, - ); - const supervisedPolicy = buildKiloChildAgentPolicyEnvironment({ - environment: baseEnvironment, - nonce: yield* randomUUIDv4, - policy: input.runtimeMode === "full-access" ? "full-access" : "ask", - label: input.runtimeMode === "full-access" ? "T3 full access code" : "T3 supervised code", - prompt: - input.runtimeMode === "full-access" - ? "You are a coding agent running in T3 Code full-access mode. Implement the user's request." - : "You are a coding agent supervised by T3 Code. Use tools as needed, but every tool permission is decided by the T3 client.", - }); - if (!supervisedPolicy.ok) { - return yield* new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: supervisedPolicy.message, - }); - } - const supervisedModeId = supervisedPolicy.modeId; - const planPolicy = buildKiloChildAgentPolicyEnvironment({ - environment: supervisedPolicy.environment, - nonce: `${yield* randomUUIDv4}-plan`, - policy: "plan", - label: "T3 read-only plan", - prompt: - "Create and explain a read-only implementation plan. Do not modify files, execute commands, ask questions, or delegate tasks.", - }); - if (!planPolicy.ok) { - return yield* new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: planPolicy.message, - }); - } - const planModeId = planPolicy.modeId; - const childEnvironment = planPolicy.environment; + const effectiveKiloSettings = options?.resolveSettings + ? yield* options.resolveSettings + : kiloSettings; + yield* ensureSupportedKiloVersion(effectiveKiloSettings, input.threadId); + const baseEnvironment = enforceKiloInteractiveModeEnvironment( + options?.environment ?? process.env, + input.runtimeMode, + ); + const supervisedPolicy = buildKiloChildAgentPolicyEnvironment({ + environment: baseEnvironment, + nonce: yield* randomUUIDv4, + policy: input.runtimeMode === "full-access" ? "full-access" : "ask", + label: + input.runtimeMode === "full-access" ? "T3 full access code" : "T3 supervised code", + prompt: + input.runtimeMode === "full-access" + ? "You are a coding agent running in T3 Code full-access mode. Implement the user's request." + : "You are a coding agent supervised by T3 Code. Use tools as needed, but every tool permission is decided by the T3 client.", + }); + if (!supervisedPolicy.ok) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: supervisedPolicy.message, + }); + } + const supervisedModeId = supervisedPolicy.modeId; + const planPolicy = buildKiloChildAgentPolicyEnvironment({ + environment: supervisedPolicy.environment, + nonce: `${yield* randomUUIDv4}-plan`, + policy: "plan", + label: "T3 read-only plan", + prompt: + "Create and explain a read-only implementation plan. Do not modify files, execute commands, ask questions, or delegate tasks.", + }); + if (!planPolicy.ok) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: planPolicy.message, + }); + } + const planModeId = planPolicy.modeId; + const childEnvironment = planPolicy.environment; - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const startupTimeout = Duration.fromInputUnsafe( - options?.startupTimeout ?? KILO_USER_SESSION_STARTUP_TIMEOUT, - ); - const startup = yield* startKiloAcpRuntime( - { - kiloSettings: effectiveKiloSettings, - ...(childEnvironment ? { environment: childEnvironment } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const startupTimeout = Duration.fromInputUnsafe( + options?.startupTimeout ?? KILO_USER_SESSION_STARTUP_TIMEOUT, + ); + const startup = yield* startAcpRuntime( + { + kiloSettings: effectiveKiloSettings, + ...(childEnvironment ? { environment: childEnvironment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ { - name: "Authorization", - value: mcpSession.authorizationHeader, + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], }, ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }, - (acp) => - acp.handleRequestPermission((params) => - mapHandlerFailure( - Effect.gen(function* () { - yield* logNative(input.threadId, "session/request_permission", params); - const permissionRequest = parsePermissionRequest(params); - const permissionFingerprint = yield* makePermissionFingerprint(params); - const autoApprove = - input.runtimeMode === "full-access" || - (input.runtimeMode === "auto-accept-edits" && - permissionRequest.kind === "edit" && - permissionFingerprint !== undefined) || - (permissionFingerprint !== undefined && - sessionApprovedPermissionFingerprints.has(permissionFingerprint)); - if (autoApprove) { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { + } + : {}), + ...acpNativeLoggers, + }, + (acp) => + acp.handleRequestPermission((params) => + mapHandlerFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + const permissionRequest = parsePermissionRequest(params); + const permissionFingerprint = yield* makePermissionFingerprint(params); + const registration = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const turnId = ctx?.activeTurnId ?? ctx?.session.activeTurnId; + if ( + ctx === undefined || + sessions.get(input.threadId) !== ctx || + ctx.stopped || + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) + ) { + return { + _tag: "Resolved" as const, + response: { outcome: { outcome: "cancelled" as const } }, + }; + } + + const autoApprove = + input.runtimeMode === "full-access" || + (input.runtimeMode === "auto-accept-edits" && + permissionRequest.kind === "edit" && + permissionFingerprint !== undefined) || + (permissionFingerprint !== undefined && + sessionApprovedPermissionFingerprints.has(permissionFingerprint)); + if (autoApprove) { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + _tag: "Resolved" as const, + response: { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }, + }; + } + } + + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + permissionFingerprint, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + return { + _tag: "Pending" as const, + decision, + runtimeRequestId, + turnId, + }; + }), + ); + if (registration._tag === "Resolved") { + return registration.response; + } + + const resolved = yield* Deferred.await(registration.decision); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: registration.turnId, + requestId: registration.runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" + ? undefined + : selectPermissionOptionId(params, resolved); return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), }; - } - } - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - permissionFingerprint, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: - permissionRequest.detail ?? - encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? - "[unserializable params]", - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - const selectedOptionId = - resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); - return { - outcome: selectedOptionId - ? { - outcome: "selected" as const, - optionId: selectedOptionId, - } - : ({ outcome: "cancelled" } as const), - }; - }), + ), + ), + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), ), - ), - ).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - Effect.timeoutOption(startupTimeout), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: `Kilo ACP did not become ready within ${Duration.format(startupTimeout)}. Check that \`kilo acp\` starts successfully and that no other Kilo startup is stuck, then retry.`, - }), + Effect.flatMap(({ runtime: acp, started }) => + applyRequestedSessionConfiguration({ + runtime: acp, + threadId: input.threadId, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + requestedModelId: kiloModelSelection?.model, + supervisedModeId, + planModeId, + }).pipe( + Effect.map((configuredModel) => ({ + runtime: acp, + started, + configuredModel, + })), ), - onSome: Effect.succeed, - }), - ), - Effect.onError(() => Scope.close(sessionScope, Exit.void)), - ); - const { runtime: acp, started } = startup; - - const configuredModel = yield* applyRequestedSessionConfiguration({ - runtime: acp, - threadId: input.threadId, - runtimeMode: input.runtimeMode, - interactionMode: undefined, - requestedModelId: kiloModelSelection?.model, - supervisedModeId, - planModeId, - }); + ), + Effect.timeoutOption(startupTimeout), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: `Kilo ACP did not complete startup and initial configuration within ${Duration.format(startupTimeout)}. Check that \`kilo acp\` starts successfully and that no other Kilo startup is stuck, then retry.`, + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.onError(() => Scope.close(sessionScope, Exit.void)), + ); + const { runtime: acp, started, configuredModel } = startup; - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - model: configuredModel, - threadId: input.threadId, - resumeCursor: { - schemaVersion: KILO_RESUME_VERSION, - sessionId: started.sessionId, - }, - createdAt: now, - updatedAt: now, - }; + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: configuredModel, + threadId: input.threadId, + resumeCursor: { + schemaVersion: KILO_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; - ctx = { - threadId: input.threadId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - sessionApprovedPermissionFingerprints, - supervisedModeId, - planModeId, - turns: [], - interruptedTurnIds: new Set(), - lastPlanFingerprint: undefined, - activeTurnId: undefined, - promptsInFlight: 0, - stopped: false, - }; + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + sessionApprovedPermissionFingerprints, + supervisedModeId, + planModeId, + turns: [], + interruptedTurnIds: new Set(), + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.getEvents(), (event) => - Effect.gen(function* () { - switch (event._tag) { - case "EventStreamBarrier": - yield* Deferred.succeed(event.acknowledge, undefined); - return; - case "ModeChanged": - return; - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); - return; - case "ToolCallUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe( - Effect.catch((cause) => - Effect.logError("Failed to process Kilo runtime notification.", { cause }), - ), - Effect.forkIn(ctx.scope), - ); + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + if (event._tag === "ModeChanged") return; + + // ACP session notifications do not carry a T3 turn id. Drop + // output when no turn owns the stream, or while cancellation + // is settling, instead of attaching stale output to mutable + // session state after a terminal event. + const notificationTurnId = ctx.activeTurnId; + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Kilo runtime notification.", { cause }), + ), + Effect.forkIn(ctx.scope), + ); - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; + yield* ensureSessionStartIsCurrent(input.threadId, startGeneration); + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { state: "ready", reason: "Kilo ACP session ready" }, - }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Kilo ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); - return session; - }).pipe(Effect.scoped), + return session; + }).pipe(Effect.scoped), + ), + () => unregisterSessionStart(input.threadId), ); const releasePreparedPrompt = (ctx: KiloSessionContext, turnId: TurnId) => @@ -1328,6 +1491,17 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( // updates and deltas retain this turn id and land before completion // or any next-turn preparation. yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + // interruptTurn owns terminal settlement once it marks this turn. + // In particular, a locally interrupted prompt must not expose the + // session as ready before remote cancellation is confirmed. + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } if ( ctx.activeTurnId !== prepared.turnId || ctx.session.activeTurnId !== prepared.turnId || @@ -1411,22 +1585,50 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( threadId, }); } - yield* withThreadLock( + const cancelTarget = yield* withThreadLock( threadId, Effect.gen(function* () { const ctx = yield* requireSession(threadId); - if (ctx !== observed.ctx) return; + if (ctx !== observed.ctx) return undefined; yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); + return ctx; + }), + ); + if (!cancelTarget) return; + + // Release the preparation lock while awaiting the remote cancellation. + // A permission handler already inside logging/fingerprinting can then + // acquire the lock, observe the interrupted turn, and answer cancelled + // so the remote prompt is able to settle. + const cancelExit = yield* cancelTarget.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + Effect.exit, + ); + if (Exit.isFailure(cancelExit)) { + yield* withThreadLock( + threadId, + Effect.uninterruptible( + Effect.gen(function* () { + if (sessions.get(threadId) !== cancelTarget || cancelTarget.stopped) return; + yield* quarantineSessionInternal( + cancelTarget, + "Kilo cancellation could not be confirmed. The session was terminated to prevent overlapping remote work.", + ); + }), + ), + ); + return yield* Effect.failCause(cancelExit.cause); + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + if (sessions.get(threadId) !== cancelTarget || cancelTarget.stopped) return; if (observed.interruptedTurnId !== undefined) { - yield* Effect.ignore(ctx.acp.drainEvents); - yield* settlePromptInFlight(ctx, observed.interruptedTurnId, { + yield* Effect.ignore(cancelTarget.acp.drainEvents); + yield* settlePromptInFlight(cancelTarget, observed.interruptedTurnId, { stopReason: "cancelled", settleAllPrompts: true, }); @@ -1438,30 +1640,38 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( const respondToRequest: KiloAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( function* (threadId, requestId, decision) { - const ctx = yield* requireSession(threadId); - const pending = yield* Effect.sync(() => { - const claimed = ctx.pendingApprovals.get(requestId); - if (claimed) ctx.pendingApprovals.delete(requestId); - return claimed; - }); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Unknown pending approval request: ${requestId}`, - }); - } - const resolved = yield* Deferred.succeed(pending.decision, decision); - if (!resolved) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Approval request is already resolved: ${requestId}`, - }); - } - if (decision === "acceptForSession" && pending.permissionFingerprint !== undefined) { - ctx.sessionApprovedPermissionFingerprints.add(pending.permissionFingerprint); - } + return yield* withThreadLock( + threadId, + Effect.uninterruptible( + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = yield* Effect.sync(() => { + const claimed = ctx.pendingApprovals.get(requestId); + if (claimed) ctx.pendingApprovals.delete(requestId); + return claimed; + }); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* options?.afterApprovalClaim?.(requestId) ?? Effect.void; + const resolved = yield* Deferred.succeed(pending.decision, decision); + if (!resolved) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Approval request is already resolved: ${requestId}`, + }); + } + if (decision === "acceptForSession" && pending.permissionFingerprint !== undefined) { + ctx.sessionApprovedPermissionFingerprints.add(pending.permissionFingerprint); + } + }), + ), + ); }, ); @@ -1519,10 +1729,38 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( }); const stopAll: KiloAdapterShape["stopAll"] = () => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + Effect.uninterruptible( + Effect.gen(function* () { + const threadIds = yield* Effect.sync(() => { + stopAllInProgressCount += 1; + stopAllGeneration += 1; + return Array.from(new Set([...sessions.keys(), ...startingSessionCounts.keys()])); + }); + yield* Effect.forEach( + threadIds, + (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (ctx && !ctx.stopped) { + yield* stopSessionInternal(ctx); + } + }), + ), + { discard: true }, + ).pipe( + Effect.ensuring( + Effect.sync(() => { + stopAllInProgressCount = Math.max(0, stopAllInProgressCount - 1); + }), + ), + ); + }), + ); yield* Effect.addFinalizer(() => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + stopAll().pipe( Effect.catch((cause) => Effect.logError("Failed to emit Kilo session shutdown event.", { cause }), ), diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..24000a62aa12 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -6,6 +6,7 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -269,6 +270,81 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("cancels when cancellation lands before the prompt fiber is registered", () => { + return Effect.gen(function* () { + const promptFiberCreated = yield* Deferred.make(); + const releaseRegistration = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + beforePromptFiberRegistration: Deferred.succeed(promptFiberCreated, undefined).pipe( + Effect.andThen(Deferred.await(releaseRegistration)), + ), + }); + yield* runtime.start(); + + const promptFiber = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "cancel during registration" }], + }) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(promptFiberCreated); + yield* runtime.cancel; + yield* Deferred.succeed(releaseRegistration, undefined); + + expect(yield* Fiber.join(promptFiber)).toMatchObject({ stopReason: "cancelled" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }); + + it.effect("fails cancellation when the active prompt does not acknowledge it in time", () => { + return Effect.gen(function* () { + const promptFiberCreated = yield* Deferred.make(); + const cancellationWaiting = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_IGNORE_PROMPT_CANCEL_SETTLEMENT: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + cancelSettleTimeout: "1 second", + beforePromptFiberRegistration: Deferred.succeed(promptFiberCreated, undefined), + beforeCancelSettlementWait: Deferred.succeed(cancellationWaiting, undefined), + }); + yield* runtime.start(); + + const promptFiber = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "ignore cancellation" }], + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(promptFiberCreated); + const cancellation = yield* runtime.cancel.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(cancellationWaiting); + yield* TestClock.adjust("1 second"); + + expect(yield* Fiber.join(cancellation)).toMatchObject({ + _tag: "AcpTransportError", + method: "session/cancel", + }); + expect(yield* Fiber.join(promptFiber)).toMatchObject({ stopReason: "cancelled" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }); + it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 5a2f87b4674f..31847deff233 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -72,6 +72,16 @@ export interface AcpSessionRuntimeOptions { readonly authMethodId: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; + /** Optional prompt-lifecycle instrumentation used by deterministic runtime tests. */ + readonly beforePromptFiberRegistration?: Effect.Effect; + /** + * When set, cancellation waits for the active prompt RPC to settle after + * sending `session/cancel`. Providers that acknowledge cancellation through + * the prompt response can use this as a remote settlement barrier. + */ + readonly cancelSettleTimeout?: Duration.Input; + /** Optional cancellation-lifecycle instrumentation used by deterministic runtime tests. */ + readonly beforeCancelSettlementWait?: Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -752,7 +762,18 @@ export const make = ( requestPayload, acp.agent.prompt(requestPayload), ).pipe(Effect.forkIn(runtimeScope)); + yield* options.beforePromptFiberRegistration ?? Effect.void; yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + // Cancellation can land after the generation check above but + // before the active fiber is registered. Rechecking after the + // registration closes that window: cancellation either sees + // this fiber or this prompt observes the newer generation. + const registeredGeneration = yield* Ref.get(promptGenerationRef); + if (registeredGeneration !== queuedGeneration) { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* Ref.set(activePromptFiberRef, Option.none()); + return cancelledResponse; + } return yield* Fiber.join(promptRpcFiber).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) @@ -776,18 +797,36 @@ export const make = ( ); }), cancel: Effect.gen(function* () { + const started = yield* getStartedState; + // Confirm the cancellation notification was written before releasing + // the local prompt. If the transport write fails, callers must keep the + // session non-ready because the remote agent may still be working. + yield* acp.agent.cancel({ sessionId: started.sessionId }); // Invalidate prompts already waiting on the serialization permit before // interrupting the active RPC. Callers may additionally provide a // shouldStart guard for work prepared concurrently with cancellation. yield* Ref.update(promptGenerationRef, (generation) => generation + 1); - const started = yield* getStartedState; const activePromptFiber = yield* Ref.get(activePromptFiberRef); if (Option.isSome(activePromptFiber)) { + if (options.cancelSettleTimeout === undefined) { + yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + return; + } + yield* options.beforeCancelSettlementWait ?? Effect.void; + const settled = yield* Fiber.await(activePromptFiber.value).pipe( + Effect.timeoutOption(options.cancelSettleTimeout), + ); + if (Option.isSome(settled)) return; yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + return yield* new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/cancel", + detail: `The active ACP prompt did not settle within ${Duration.format( + Duration.fromInputUnsafe(options.cancelSettleTimeout), + )} after cancellation.`, + cause: "ACP prompt cancellation settlement timed out", + }); } - yield* acp.agent - .cancel({ sessionId: started.sessionId }) - .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); }), setMode: (modeId) => Ref.get(modeStateRef).pipe( diff --git a/apps/server/src/provider/acp/KiloAcpSupport.test.ts b/apps/server/src/provider/acp/KiloAcpSupport.test.ts index 98cdd0eff3fa..3dcdb7a55d1c 100644 --- a/apps/server/src/provider/acp/KiloAcpSupport.test.ts +++ b/apps/server/src/provider/acp/KiloAcpSupport.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import * as itx from "@effect/vitest"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; -import type * as EffectAcpErrors from "effect-acp/errors"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { @@ -16,6 +17,7 @@ import { hardenKiloProbeEnvironment, kiloModelsFromSessionConfigOptions, KILO_PROVIDER_DEFAULT_MODEL_ID, + retryKiloAcpInitialization, withKiloAcpStartupPermit, } from "./KiloAcpSupport.ts"; @@ -219,6 +221,56 @@ describe("kilo ACP support", () => { }), ); + itx.it.effect("retries only ACP initialize transport failures", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const result = yield* retryKiloAcpInitialization( + () => + Ref.updateAndGet(attempts, (count) => count + 1).pipe( + Effect.flatMap((attempt) => + attempt < 3 + ? Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "initialize", + cause: new Error("Kilo exited during migration"), + }), + ) + : Effect.succeed("ready"), + ), + ), + [Duration.zero, Duration.zero], + ); + + expect(result).toBe("ready"); + expect(yield* Ref.get(attempts)).toBe(3); + }), + ); + + itx.it.effect("does not retry other ACP startup failures", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const result = yield* retryKiloAcpInitialization( + () => + Ref.update(attempts, (count) => count + 1).pipe( + Effect.andThen( + Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/new", + cause: new Error("request failed"), + }), + ), + ), + ), + [Duration.zero], + ).pipe(Effect.exit); + + expect(Exit.isFailure(result)).toBe(true); + expect(yield* Ref.get(attempts)).toBe(1); + }), + ); + it("reads the current model id from model-category config options", () => { const current = currentKiloModelIdFromSessionSetup({ sessionId: "ses_1", diff --git a/apps/server/src/provider/acp/KiloAcpSupport.ts b/apps/server/src/provider/acp/KiloAcpSupport.ts index 907641d68656..c33b33f1edf6 100644 --- a/apps/server/src/provider/acp/KiloAcpSupport.ts +++ b/apps/server/src/provider/acp/KiloAcpSupport.ts @@ -11,11 +11,12 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import type * as EffectAcpErrors from "effect-acp/errors"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -23,6 +24,12 @@ import { findSessionConfigOption } from "./AcpRuntimeModel.ts"; const KILO_AUTH_METHOD_ID = "kilo-login"; const KILO_ACP_STARTUP_SEMAPHORE = Semaphore.makeUnsafe(1); +const KILO_ACP_INITIALIZE_RETRY_DELAYS = [ + Duration.millis(500), + Duration.seconds(1), + Duration.seconds(2), +] as const; +const KILO_CANCEL_SETTLE_TIMEOUT = Duration.seconds(5); const KILO_CHILD_FORCE_KILL_AFTER = Duration.seconds(2); export const KILO_PROVIDER_DEFAULT_MODEL_ID = "__t3_provider_default__"; const T3_GENERIC_MODEL_FALLBACKS = new Set([ @@ -220,6 +227,7 @@ const makeKiloAcpRuntime = Effect.fn("makeKiloAcpRuntime")(function* ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, + cancelSettleTimeout: input.cancelSettleTimeout ?? KILO_CANCEL_SETTLE_TIMEOUT, spawn: buildKiloAcpSpawnInput( input.kiloSettings, input.cwd, @@ -242,6 +250,40 @@ const makeKiloAcpRuntime = Effect.fn("makeKiloAcpRuntime")(function* ( export const withKiloAcpStartupPermit = (effect: Effect.Effect) => KILO_ACP_STARTUP_SEMAPHORE.withPermit(effect); +const isAcpTransportError = Schema.is(EffectAcpErrors.AcpTransportError); + +/** + * Kilo's first process may be performing its one-time database migration when + * another T3 server starts Kilo against the same user data. The losing child + * exits during ACP initialization. Recreate only that pre-session attempt; + * other startup failures remain immediate and unchanged. + */ +export const retryKiloAcpInitialization = Effect.fn("retryKiloAcpInitialization")(function* < + A, + E, + R, +>( + makeAttempt: () => Effect.Effect, + retryDelays: ReadonlyArray = KILO_ACP_INITIALIZE_RETRY_DELAYS, +): Effect.fn.Return { + let retryIndex = 0; + while (true) { + const result = yield* Effect.result(makeAttempt()); + if (Result.isSuccess(result)) return result.success; + + const error = result.failure; + const isRetryable = + isAcpTransportError(error) && error.operation === "call-rpc" && error.method === "initialize"; + const retryDelay = retryDelays[retryIndex]; + if (!isRetryable || retryDelay === undefined) { + return yield* Effect.fail(error); + } + + yield* Effect.sleep(retryDelay); + retryIndex += 1; + } +}); + /** * Builds and starts one Kilo ACP runtime while holding the process-local * startup permit. Kilo binds its internal HTTP listener before it begins ACP @@ -264,11 +306,29 @@ export const startKiloAcpRuntime = Effect.fn("startKiloAcpRuntime")(function* < EffectAcpErrors.AcpError | E, Crypto.Crypto | Scope.Scope | R > { + const parentScope = yield* Scope.Scope; return yield* withKiloAcpStartupPermit( - makeKiloAcpRuntime(input).pipe( - Effect.tap(configureRuntime), - Effect.flatMap((runtime) => - runtime.start().pipe(Effect.map((started) => ({ runtime, started }))), + retryKiloAcpInitialization(() => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const attemptScope = yield* Scope.fork(parentScope); + const attemptExit = yield* Effect.exit( + restore( + makeKiloAcpRuntime(input).pipe( + Effect.tap(configureRuntime), + Effect.flatMap((runtime) => + runtime.start().pipe(Effect.map((started) => ({ runtime, started }))), + ), + Effect.provideService(Scope.Scope, attemptScope), + ), + ), + ); + if (Exit.isFailure(attemptExit)) { + yield* Scope.close(attemptScope, attemptExit).pipe(Effect.ignore); + return yield* Effect.failCause(attemptExit.cause); + } + return attemptExit.value; + }), ), ), ); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 3a38a91e2265..21f73f274c90 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -370,8 +370,8 @@ export function EnvironmentProviderSettings({ }) { const settings = useEnvironmentSettings(environmentId); const updateSettings = useUpdateEnvironmentSettings(environmentId); - const serverProviders = - useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const serverProvidersValue = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const serverProviders = serverProvidersValue ?? EMPTY_SERVER_PROVIDERS; const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -403,7 +403,9 @@ export function EnvironmentProviderSettings({ provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), ), ); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders, { + providersLoaded: serverProvidersValue !== null, + }); const textGenInstanceId = textGenerationModelSelection.instanceId; const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const providerHealthPreset = getBackgroundActivityPresetSettings( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b4a0ec3cad5c..1662b43d4e9b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -75,11 +75,16 @@ import { import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + isNoProviderModelSelection, sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; +import { + primaryServerConfigAtom, + primaryServerObservabilityAtom, + primaryServerProvidersAtom, +} from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -1861,7 +1866,9 @@ export function GeneralSettingsPanel() { readLastEnabledProjectGroupingMode(), ); const observability = useAtomValue(primaryServerObservabilityAtom); + const serverConfig = useAtomValue(primaryServerConfigAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerSnapshotsLoaded = serverConfig !== null; const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -1870,10 +1877,13 @@ export function GeneralSettingsPanel() { otlpMetricsUrl: observability?.otlpMetricsUrl, }); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders, { + providersLoaded: providerSnapshotsLoaded, + }); const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; + const hasTextGenerationProvider = !isNoProviderModelSelection(textGenerationModelSelection); const textGenerationModelInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ).filter((entry) => entry.supportsTextGeneration); @@ -2414,59 +2424,73 @@ export function GeneralSettingsPanel() { } control={
- { - updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState( - { - ...settings, - textGenerationModelSelection: createModelSelection(instanceId, model), - }, - serverProviders, - ), - }); - }} - /> - {}} - modelOptions={textGenModelOptions} - allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} - triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" - onModelOptionsChange={(nextOptions) => { - updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState( - { - ...settings, - textGenerationModelSelection: createModelSelection( - textGenInstanceId, - textGenModel, - nextOptions, + {!providerSnapshotsLoaded ? ( + + ) : hasTextGenerationProvider ? ( + <> + { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: createModelSelection(instanceId, model), + }, + serverProviders, + { providersLoaded: providerSnapshotsLoaded }, ), - }, - serverProviders, - ), - }); - }} - /> + }); + }} + /> + {}} + modelOptions={textGenModelOptions} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + onModelOptionsChange={(nextOptions) => { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: createModelSelection( + textGenInstanceId, + textGenModel, + nextOptions, + ), + }, + serverProviders, + { providersLoaded: providerSnapshotsLoaded }, + ), + }); + }} + /> + + ) : ( + + )}
} /> diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts new file mode 100644 index 000000000000..492ab2ca47d1 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts @@ -0,0 +1,31 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { NO_PROVIDER_MODEL_SELECTION } from "../../providerInstances"; +import { resolveSourceControlWriterToggleSelection } from "./SourceControlWritingSettings.logic"; + +describe("resolveSourceControlWriterToggleSelection", () => { + it("copies an available default when enabling the override", () => { + expect( + resolveSourceControlWriterToggleSelection(true, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }), + ).toEqual({ + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }); + }); + + it("does not persist the local no-provider sentinel", () => { + expect(resolveSourceControlWriterToggleSelection(true, NO_PROVIDER_MODEL_SELECTION)).toBe( + undefined, + ); + }); + + it("clears the override when disabling it", () => { + expect( + resolveSourceControlWriterToggleSelection(false, NO_PROVIDER_MODEL_SELECTION), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts new file mode 100644 index 000000000000..7f267d107360 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts @@ -0,0 +1,21 @@ +import type { ModelSelection } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +import { isNoProviderModelSelection } from "../../providerInstances"; + +/** + * Resolves the source-control writer toggle without ever persisting the + * local-only no-provider sentinel. `undefined` means enabling is unavailable. + */ +export function resolveSourceControlWriterToggleSelection( + checked: boolean, + defaultSelection: ModelSelection, +): ModelSelection | null | undefined { + if (!checked) return null; + if (isNoProviderModelSelection(defaultSelection)) return undefined; + return createModelSelection( + defaultSelection.instanceId, + defaultSelection.model, + defaultSelection.options, + ); +} diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 3b2e98171f34..ca27675249a2 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -2,25 +2,27 @@ import { useAtomValue } from "@effect/atom-react"; import { useRef } from "react"; import type { SourceControlWritingStyleMode } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { createModelSelection } from "@t3tools/shared/model"; import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + isNoProviderModelSelection, sortProviderInstanceEntries, } from "../../providerInstances"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; -import { primaryServerProvidersAtom } from "../../state/server"; +import { primaryServerConfigAtom, primaryServerProvidersAtom } from "../../state/server"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { resolveSourceControlWriterToggleSelection } from "./SourceControlWritingSettings.logic"; const MODE_OPTIONS: Record = { @@ -43,14 +45,19 @@ const MODE_OPTIONS: Record(null); const style = settings.sourceControlWritingStyle; const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; const isSourceControlWritingStyleDirty = style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; - const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders, { + providersLoaded: providerSnapshotsLoaded, + }); + const hasTextGenerationProvider = !isNoProviderModelSelection(defaultModelSelection); const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection( settings, @@ -173,7 +180,11 @@ export function SourceControlWritingSettingsSection() { description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model." control={
- {usesDedicatedModel ? ( + {!providerSnapshotsLoaded ? ( + + ) : usesDedicatedModel && hasTextGenerationProvider ? ( { updateSettings({ - sourceControlWriterModelSelection: createModelSelection(instanceId, model), + sourceControlWriterModelSelection: { instanceId, model }, }); }} /> + ) : !hasTextGenerationProvider ? ( + ) : null} - updateSettings({ - sourceControlWriterModelSelection: checked - ? createModelSelection( - defaultModelSelection.instanceId, - defaultModelSelection.model, - defaultModelSelection.options, - ) - : null, - }) + disabled={ + !usesDedicatedModel && (!providerSnapshotsLoaded || !hasTextGenerationProvider) } + onCheckedChange={(checked) => { + const nextSelection = resolveSourceControlWriterToggleSelection( + Boolean(checked), + defaultModelSelection, + ); + if (nextSelection === undefined) return; + updateSettings({ sourceControlWriterModelSelection: nextSelection }); + }} aria-label="Use a separate source control writer model" />
diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index f2757ebcea1f..2ea31e4382cf 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -389,6 +389,34 @@ describe("instance-scoped model selection", () => { }); }); + it("preserves the stored selection while provider snapshots are still loading", () => { + const selection = createModelSelection( + ProviderInstanceId.make("claude_openrouter"), + "openai/gpt-5.5", + ); + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + textGenerationModelSelection: selection, + }; + + expect(resolveAppModelSelectionState(settings, [], { providersLoaded: false })).toBe(selection); + }); + + it("returns the no-provider sentinel for an authoritative empty snapshot", () => { + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + textGenerationModelSelection: createModelSelection( + ProviderInstanceId.make("claude_openrouter"), + "openai/gpt-5.5", + ), + }; + + expect(resolveAppModelSelectionState(settings, [], { providersLoaded: true })).toEqual({ + instanceId: ProviderInstanceId.make("t3code_no_provider"), + model: "", + }); + }); + it("returns the no-provider sentinel when only Kilo can run interactive prompts", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 1607b5a968fb..f25a3841d183 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -334,11 +334,18 @@ export function resolvePlanAgentHealPatch(input: { export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, + options?: { readonly providersLoaded?: boolean }, ): ModelSelection { const selection = settings.textGenerationModelSelection ?? { instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_TEXT_GENERATION_MODEL, }; + // Provider snapshots arrive after settings during bootstrap/reconnect. + // Preserve routing identity only while that snapshot is actually absent; + // an authoritative empty snapshot means no background provider exists. + if (providers.length === 0 && options?.providersLoaded === false) { + return selection; + } const entries = deriveProviderInstanceEntries(providers); const selectedEntry = entries.find( (entry) => diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 309ca13c0820..4b0b2c117e14 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -39,6 +39,10 @@ export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = { model: "", }; +export function isNoProviderModelSelection(selection: ModelSelection): boolean { + return selection.instanceId === NO_PROVIDER_MODEL_SELECTION.instanceId; +} + /** * UI-facing projection of one configured provider instance. Carries the * snapshot verbatim for callers that need server-side fields we don't diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 1ab0992c6144..5c020d782a0c 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -27,10 +27,12 @@ transport, config, and event shapes are mapped. ### Kilo Code ACP constraints Kilo Code uses `kilo acp`. Kilo 7.4 starts an internal HTTP listener for each ACP child and tries -port 4096 before falling back to an OS-assigned port. Simultaneous child starts can race on that -first bind, so the Kilo runtime serializes startup within one T3 server; already-started Kilo -sessions remain concurrent. Separate T3 server processes cannot share that in-memory gate and retain -the narrow simultaneous-start race. +port 4096 before falling back to an OS-assigned port. The port fallback supports concurrent warm +children, but Kilo's first-run database bootstrap does not exclude another process from the same +one-time migration. The Kilo runtime therefore serializes startup within one T3 server and retries +typed initialization exits; already-started Kilo sessions remain concurrent. Separate T3 server +processes cannot share that in-memory gate, so simultaneous first starts against the same fresh Kilo +data directory can still fail or wait for T3 Code's bounded startup deadline. The current Kilo ACP bridge exposes session load/resume, model and mode configuration, images, MCP, and permissions. It does not forward Kilo question events or expose provider-side rollback. The From e7055b1a673cb914d89bf80381f0e42daa73827d Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 23:01:05 -0400 Subject: [PATCH 3/8] fix(providers): close Kilo lifecycle gaps --- apps/marketing/src/pages/index.astro | 3 +- .../src/provider/Layers/KiloAdapter.test.ts | 263 ++++++++++++++++-- .../server/src/provider/Layers/KiloAdapter.ts | 54 ++-- .../provider/acp/AcpJsonRpcConnection.test.ts | 61 ++++ .../src/provider/acp/AcpSessionRuntime.ts | 26 +- .../server/src/provider/acp/KiloAcpSupport.ts | 2 + .../components/settings/SettingsPanels.tsx | 11 +- ...SourceControlWritingSettings.logic.test.ts | 20 +- .../SourceControlWritingSettings.logic.ts | 5 +- .../settings/SourceControlWritingSettings.tsx | 12 +- apps/web/src/providerInstances.test.ts | 21 ++ apps/web/src/providerInstances.ts | 22 +- 12 files changed, 440 insertions(+), 60 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 9ccd59496e7c..433b7ca40389 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -1275,6 +1275,7 @@ const mobileEndorsementRows = [ .harness-grid { grid-template-columns: 1fr 1fr; } .harness { border-right: 0; border-bottom: 1px solid var(--border); } .harness:nth-child(odd) { border-right: 1px solid var(--border); } + .harness:nth-child(n) { border-bottom: 1px solid var(--border); } .harness:nth-child(n + 5) { border-bottom: 0; } .git-inner { grid-template-columns: 1fr; gap: 40px; } .open-grid { grid-template-columns: 1fr; } @@ -1317,7 +1318,7 @@ const mobileEndorsementRows = [ @media (max-width: 520px) { .harness-grid { grid-template-columns: 1fr; } .harness, - .harness:nth-child(odd) { + .harness:nth-child(n) { border-right: 0; border-bottom: 1px solid var(--border); } diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index b7c096eda5fc..11f0952ccb85 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -29,6 +29,7 @@ import { type RuntimeMode, ThreadId, type ProviderRuntimeEvent, + TurnId, } from "@t3tools/contracts"; import { attachmentRelativePath } from "../../attachmentStore.ts"; @@ -36,6 +37,7 @@ import { ServerConfig } from "../../config.ts"; import { ProviderAdapterRequestError } from "../Errors.ts"; import { KILO_PROVIDER_DEFAULT_MODEL_ID, startKiloAcpRuntime } from "../acp/KiloAcpSupport.ts"; import { + type KiloThreadLockRegistry, makeKiloAdapter, makeKiloThreadLockRegistry, resolveKiloRequestedModeId, @@ -1264,7 +1266,158 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); - it.effect("quarantines the session when ACP cancellation cannot be sent", () => + it.effect("ignores an interrupt addressed to a stale turn id", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stale-turn-interrupt"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const activeTurnStarted = yield* Deferred.make(); + const promptStarted = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "turn.started" + ? Deferred.succeed(activeTurnStarted, event.turnId).pipe(Effect.ignore) + : event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "keep the current turn running", attachments: [] }) + .pipe(Effect.forkChild); + const activeTurnId = yield* Deferred.await(activeTurnStarted); + assert.ok(activeTurnId); + yield* Deferred.await(promptStarted); + + yield* adapter.interruptTurn(threadId, TurnId.make("stale-turn-id")); + const [session] = yield* adapter.listSessions(); + assert.equal(session?.status, "running"); + assert.equal(session?.activeTurnId, activeTurnId); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 0); + + yield* adapter.interruptTurn(threadId, activeTurnId); + yield* Fiber.join(send); + const completed = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.equal(completed.length, 1); + if (completed[0]?.type === "turn.completed") { + assert.equal(completed[0].turnId, activeTurnId); + assert.equal(completed[0].payload.state, "cancelled"); + } + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("revalidates a stale interrupt after a newer turn wins the thread lock", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stale-turn-lock-revalidation"); + const wrapperPath = yield* Effect.promise(() => makeMockKiloWrapper()); + const baseThreadLocks = yield* makeKiloThreadLockRegistry; + const nextWaiterRegistered = yield* Deferred.make(); + const staleInterruptRegistered = yield* Deferred.make(); + let observeQueuedRegistrations = false; + let queuedRegistrationCount = 0; + const threadLocks: KiloThreadLockRegistry = { + withLock: (registeredThreadId, effect) => + Effect.suspend(() => { + if (!observeQueuedRegistrations) { + return baseThreadLocks.withLock(registeredThreadId, effect); + } + const registration = queuedRegistrationCount; + queuedRegistrationCount += 1; + const receipt = + registration === 0 + ? Deferred.succeed(nextWaiterRegistered, undefined) + : registration === 1 + ? Deferred.succeed(staleInterruptRegistered, undefined) + : Effect.void; + return receipt.pipe( + Effect.andThen(baseThreadLocks.withLock(registeredThreadId, effect)), + ); + }), + activeKeyCount: baseThreadLocks.activeKeyCount, + activeUserCount: baseThreadLocks.activeUserCount, + }; + const firstSettlementLocked = yield* Deferred.make(); + const releaseFirstSettlement = yield* Deferred.make(); + const blockFirstSettlement = yield* Ref.make(true); + const adapter = yield* makeTestAdapter(wrapperPath, { + threadLockRegistry: threadLocks, + beforePromptSettlement: () => + Ref.getAndSet(blockFirstSettlement, false).pipe( + Effect.flatMap((shouldBlock) => + shouldBlock + ? Deferred.succeed(firstSettlementLocked, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstSettlement)), + ) + : Effect.void, + ), + ), + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstSend = yield* adapter + .sendTurn({ threadId, input: "finish turn A", attachments: [] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstSettlementLocked); + const [turnASession] = yield* adapter.listSessions(); + const turnAId = turnASession?.activeTurnId; + assert.ok(turnAId); + + observeQueuedRegistrations = true; + const secondSend = yield* adapter + .sendTurn({ threadId, input: "start turn B", attachments: [] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(nextWaiterRegistered); + const staleInterrupt = yield* adapter + .interruptTurn(threadId, turnAId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(staleInterruptRegistered); + yield* Deferred.succeed(releaseFirstSettlement, undefined); + + const firstReceipt = yield* Fiber.join(firstSend); + yield* Fiber.join(staleInterrupt); + const secondReceipt = yield* Fiber.join(secondSend); + assert.notEqual(firstReceipt.turnId, secondReceipt.turnId); + const completions = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.equal(completions.length, 2); + assert.isTrue( + completions.every( + (event) => event.type === "turn.completed" && event.payload.state === "completed", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("quarantines the session when the ACP cancellation transport times out", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-cancel-transport-failure"); const tempDir = yield* Effect.promise(() => @@ -1278,25 +1431,20 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", }), ); - const failingCancelStarter: typeof startKiloAcpRuntime = (input, configureRuntime) => - startKiloAcpRuntime(input).pipe( - Effect.flatMap(({ runtime, started }) => { - const wrappedRuntime = { - ...runtime, - cancel: Effect.fail( - new EffectAcpErrors.AcpTransportError({ - detail: "Mock cancellation transport failure.", - cause: new Error("cancel transport closed"), - }), - ), - }; - return (configureRuntime?.(wrappedRuntime) ?? Effect.void).pipe( - Effect.as({ runtime: wrappedRuntime, started }), - ); - }), + const cancelTransportStarted = yield* Deferred.make(); + const neverReleaseCancelTransport = yield* Deferred.make(); + const timingOutCancelStarter: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime( + { + ...input, + beforeCancelTransportWrite: Deferred.succeed(cancelTransportStarted, undefined).pipe( + Effect.andThen(Deferred.await(neverReleaseCancelTransport)), + ), + }, + configureRuntime, ); const adapter = yield* makeTestAdapter(wrapperPath, { - startAcpRuntime: failingCancelStarter, + startAcpRuntime: timingOutCancelStarter, }); const promptStarted = yield* Deferred.make(); const sessionExited = yield* Deferred.make(); @@ -1323,7 +1471,12 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { .sendTurn({ threadId, input: "keep running on failed cancel", attachments: [] }) .pipe(Effect.exit, Effect.forkChild); yield* Deferred.await(promptStarted); - const interruptExit = yield* adapter.interruptTurn(threadId).pipe(Effect.exit); + const interrupt = yield* adapter + .interruptTurn(threadId) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cancelTransportStarted); + yield* TestClock.adjust("5 seconds"); + const interruptExit = yield* Fiber.join(interrupt); yield* Deferred.await(sessionExited); yield* Fiber.join(send); @@ -1523,6 +1676,78 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect( + "continues consuming notifications and acknowledges barriers after one handler defect", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-notification-handler-defect"); + const wrapperPath = yield* Effect.promise(() => makeMockKiloWrapper()); + const failNextSessionUpdate = yield* Ref.make(true); + const failureInjected = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "memory://kilo-notification-handler-defect", + write: (record: unknown) => { + const encoded = JSON.stringify(record); + if ( + !encoded.includes('"kind":"notification"') || + !encoded.includes('"method":"session/update"') + ) { + return Effect.void; + } + return Ref.getAndSet(failNextSessionUpdate, false).pipe( + Effect.flatMap((shouldFail) => + shouldFail + ? Deferred.succeed(failureInjected, undefined).pipe( + Effect.andThen(Effect.die("mock notification logger defect")), + ) + : Effect.void, + ), + ); + }, + close: () => Effect.void, + }, + }); + const contentAfterFailure = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "hello from mock" + ? Deferred.succeed(contentAfterFailure, undefined).pipe(Effect.ignore) + : event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "continue after one bad notification", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(failureInjected); + yield* Deferred.await(contentAfterFailure); + const result = yield* Fiber.join(send); + yield* Deferred.await(turnCompleted); + + assert.equal(result.threadId, threadId); + assert.isTrue( + runtimeEvents.some( + (event) => event.type === "turn.completed" && event.payload.state === "completed", + ), + ); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("settles a prompt failure exactly once and restores the session to ready", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-prompt-failure"); diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index 554e70f885fe..e7148e950bf6 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -22,6 +22,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -209,6 +210,8 @@ export interface KiloAdapterLiveOptions { readonly threadLockRegistry?: KiloThreadLockRegistry; /** Optional synchronization hook after an approval is claimed. Used by focused race tests. */ readonly afterApprovalClaim?: (requestId: ApprovalRequestId) => Effect.Effect; + /** Optional synchronization hook after prompt settlement acquires the thread lock. */ + readonly beforePromptSettlement?: (turnId: TurnId) => Effect.Effect; /** Overrides ACP runtime startup. Used by focused adapter-boundary tests. */ readonly startAcpRuntime?: typeof startKiloAcpRuntime; } @@ -1224,14 +1227,18 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( ); return; } - }), - ), - ).pipe( - Effect.catch((cause) => - Effect.logError("Failed to process Kilo runtime notification.", { cause }), + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logError("Failed to process Kilo runtime notification.", { + cause, + eventTag: event._tag, + }), + ), + ), ), - Effect.forkIn(ctx.scope), - ); + ).pipe(Effect.forkIn(ctx.scope)); yield* ensureSessionStartIsCurrent(input.threadId, startGeneration); ctx.notificationFiber = nf; @@ -1476,6 +1483,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( return yield* withThreadLock( input.threadId, Effect.gen(function* () { + yield* options?.beforePromptSettlement?.(prepared.turnId) ?? Effect.void; const ctx = sessions.get(input.threadId); if (ctx !== prepared.ctx || ctx.stopped) { yield* Ref.set(promptSettled, true); @@ -1569,15 +1577,22 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( }); const interruptTurn: KiloAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId) { + function* (threadId, turnId) { + // Preparation holds the thread lock, so record cancellation intent for + // that exact connecting turn before waiting. The send path observes this + // marker before it can enter ACP. Running turns are claimed only under the + // lock below so an old turn id can never acquire a newer turn's runtime. const observed = yield* Effect.sync(() => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) return undefined; const interruptedTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (interruptedTurnId !== undefined) { + if (turnId !== undefined && interruptedTurnId !== turnId) { + return { ctx, interruptedTurnId, matchesRequestedTurn: false as const }; + } + if (interruptedTurnId !== undefined && ctx.session.status === "connecting") { ctx.interruptedTurnIds.add(interruptedTurnId); } - return { ctx, interruptedTurnId }; + return { ctx, interruptedTurnId, matchesRequestedTurn: true as const }; }); if (!observed) { return yield* new ProviderAdapterSessionNotFoundError({ @@ -1585,16 +1600,23 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( threadId, }); } - const cancelTarget = yield* withThreadLock( + if (!observed.matchesRequestedTurn || observed.interruptedTurnId === undefined) return; + const observedTurnId = observed.interruptedTurnId; + const cancellationClaim = yield* withThreadLock( threadId, Effect.gen(function* () { const ctx = yield* requireSession(threadId); - if (ctx !== observed.ctx) return undefined; + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (ctx !== observed.ctx || activeTurnId !== observedTurnId) { + return undefined; + } + ctx.interruptedTurnIds.add(observedTurnId); yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - return ctx; + return { ctx, interruptedTurnId: observedTurnId }; }), ); - if (!cancelTarget) return; + if (!cancellationClaim) return; + const cancelTarget = cancellationClaim.ctx; // Release the preparation lock while awaiting the remote cancellation. // A permission handler already inside logging/fingerprinting can then @@ -1626,9 +1648,9 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( threadId, Effect.gen(function* () { if (sessions.get(threadId) !== cancelTarget || cancelTarget.stopped) return; - if (observed.interruptedTurnId !== undefined) { + if (cancellationClaim.interruptedTurnId !== undefined) { yield* Effect.ignore(cancelTarget.acp.drainEvents); - yield* settlePromptInFlight(cancelTarget, observed.interruptedTurnId, { + yield* settlePromptInFlight(cancelTarget, cancellationClaim.interruptedTurnId, { stopReason: "cancelled", settleAllPrompts: true, }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 24000a62aa12..4107e37b3afd 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -345,6 +345,67 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); }); + it.effect("fails cancellation when the transport write never settles", () => { + return Effect.gen(function* () { + const transportWriteStarted = yield* Deferred.make(); + const neverReleaseTransport = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + cancelTransportTimeout: "1 second", + beforeCancelTransportWrite: Deferred.succeed(transportWriteStarted, undefined).pipe( + Effect.andThen(Deferred.await(neverReleaseTransport)), + ), + }); + yield* runtime.start(); + + const cancellation = yield* runtime.cancel.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(transportWriteStarted); + yield* TestClock.adjust("1 second"); + + expect(yield* Fiber.join(cancellation)).toMatchObject({ + _tag: "AcpTransportError", + method: "session/cancel", + detail: expect.stringContaining("cancellation transport did not complete"), + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }); + + it.effect( + "leaves cancellation transport unbounded unless a provider opts into a deadline", + () => { + return Effect.gen(function* () { + const transportWriteStarted = yield* Deferred.make(); + const releaseTransport = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + beforeCancelTransportWrite: Deferred.succeed(transportWriteStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseTransport)), + ), + }); + yield* runtime.start(); + + const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); + yield* Deferred.await(transportWriteStarted); + yield* TestClock.adjust("6 seconds"); + expect(cancellation.pollUnsafe()).toBeUndefined(); + yield* Deferred.succeed(releaseTransport, undefined); + yield* Fiber.join(cancellation); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }, + ); + it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 31847deff233..4a23432f4d7b 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -74,6 +74,10 @@ export interface AcpSessionRuntimeOptions { readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; /** Optional prompt-lifecycle instrumentation used by deterministic runtime tests. */ readonly beforePromptFiberRegistration?: Effect.Effect; + /** Maximum time allowed for the ACP `session/cancel` transport write. */ + readonly cancelTransportTimeout?: Duration.Input; + /** Optional cancellation-transport instrumentation used by deterministic runtime tests. */ + readonly beforeCancelTransportWrite?: Effect.Effect; /** * When set, cancellation waits for the active prompt RPC to settle after * sending `session/cancel`. Providers that acknowledge cancellation through @@ -801,7 +805,27 @@ export const make = ( // Confirm the cancellation notification was written before releasing // the local prompt. If the transport write fails, callers must keep the // session non-ready because the remote agent may still be working. - yield* acp.agent.cancel({ sessionId: started.sessionId }); + const cancelTransport = Effect.gen(function* () { + yield* options.beforeCancelTransportWrite ?? Effect.void; + yield* acp.agent.cancel({ sessionId: started.sessionId }); + }); + if (options.cancelTransportTimeout === undefined) { + yield* cancelTransport; + } else { + const transportResult = yield* cancelTransport.pipe( + Effect.timeoutOption(options.cancelTransportTimeout), + ); + if (Option.isNone(transportResult)) { + return yield* new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/cancel", + detail: `The ACP cancellation transport did not complete within ${Duration.format( + Duration.fromInputUnsafe(options.cancelTransportTimeout), + )}.`, + cause: "ACP cancellation transport timed out", + }); + } + } // Invalidate prompts already waiting on the serialization permit before // interrupting the active RPC. Callers may additionally provide a // shouldStart guard for work prepared concurrently with cancellation. diff --git a/apps/server/src/provider/acp/KiloAcpSupport.ts b/apps/server/src/provider/acp/KiloAcpSupport.ts index c33b33f1edf6..d28ff6ce6c47 100644 --- a/apps/server/src/provider/acp/KiloAcpSupport.ts +++ b/apps/server/src/provider/acp/KiloAcpSupport.ts @@ -30,6 +30,7 @@ const KILO_ACP_INITIALIZE_RETRY_DELAYS = [ Duration.seconds(2), ] as const; const KILO_CANCEL_SETTLE_TIMEOUT = Duration.seconds(5); +const KILO_CANCEL_TRANSPORT_TIMEOUT = Duration.seconds(5); const KILO_CHILD_FORCE_KILL_AFTER = Duration.seconds(2); export const KILO_PROVIDER_DEFAULT_MODEL_ID = "__t3_provider_default__"; const T3_GENERIC_MODEL_FALLBACKS = new Set([ @@ -227,6 +228,7 @@ const makeKiloAcpRuntime = Effect.fn("makeKiloAcpRuntime")(function* ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, + cancelTransportTimeout: input.cancelTransportTimeout ?? KILO_CANCEL_TRANSPORT_TIMEOUT, cancelSettleTimeout: input.cancelSettleTimeout ?? KILO_CANCEL_SETTLE_TIMEOUT, spawn: buildKiloAcpSpawnInput( input.kiloSettings, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1662b43d4e9b..6761d73935cd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -75,7 +75,7 @@ import { import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - isNoProviderModelSelection, + hasSelectableTextGenerationProviderSelection, sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; @@ -1883,10 +1883,13 @@ export function GeneralSettingsPanel() { const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; - const hasTextGenerationProvider = !isNoProviderModelSelection(textGenerationModelSelection); const textGenerationModelInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ).filter((entry) => entry.supportsTextGeneration); + const hasTextGenerationProvider = hasSelectableTextGenerationProviderSelection( + textGenerationModelSelection, + textGenerationModelInstanceEntries, + ); const textGenInstanceEntry = textGenerationModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, ); @@ -2425,7 +2428,7 @@ export function GeneralSettingsPanel() { control={
{!providerSnapshotsLoaded ? ( - ) : hasTextGenerationProvider ? ( @@ -2487,7 +2490,7 @@ export function GeneralSettingsPanel() { /> ) : ( - )} diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts index 492ab2ca47d1..0575adf53d2c 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts +++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts @@ -7,10 +7,14 @@ import { resolveSourceControlWriterToggleSelection } from "./SourceControlWritin describe("resolveSourceControlWriterToggleSelection", () => { it("copies an available default when enabling the override", () => { expect( - resolveSourceControlWriterToggleSelection(true, { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", - }), + resolveSourceControlWriterToggleSelection( + true, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + true, + ), ).toEqual({ instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-sonnet-4-6", @@ -18,14 +22,14 @@ describe("resolveSourceControlWriterToggleSelection", () => { }); it("does not persist the local no-provider sentinel", () => { - expect(resolveSourceControlWriterToggleSelection(true, NO_PROVIDER_MODEL_SELECTION)).toBe( - undefined, - ); + expect( + resolveSourceControlWriterToggleSelection(true, NO_PROVIDER_MODEL_SELECTION, false), + ).toBe(undefined); }); it("clears the override when disabling it", () => { expect( - resolveSourceControlWriterToggleSelection(false, NO_PROVIDER_MODEL_SELECTION), + resolveSourceControlWriterToggleSelection(false, NO_PROVIDER_MODEL_SELECTION, false), ).toBeNull(); }); }); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts index 7f267d107360..34605e8c02bd 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts +++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts @@ -1,8 +1,6 @@ import type { ModelSelection } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { isNoProviderModelSelection } from "../../providerInstances"; - /** * Resolves the source-control writer toggle without ever persisting the * local-only no-provider sentinel. `undefined` means enabling is unavailable. @@ -10,9 +8,10 @@ import { isNoProviderModelSelection } from "../../providerInstances"; export function resolveSourceControlWriterToggleSelection( checked: boolean, defaultSelection: ModelSelection, + hasTextGenerationProvider: boolean, ): ModelSelection | null | undefined { if (!checked) return null; - if (isNoProviderModelSelection(defaultSelection)) return undefined; + if (!hasTextGenerationProvider) return undefined; return createModelSelection( defaultSelection.instanceId, defaultSelection.model, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index ca27675249a2..24e52cded43c 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -8,7 +8,7 @@ import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSet import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - isNoProviderModelSelection, + hasSelectableTextGenerationProviderSelection, sortProviderInstanceEntries, } from "../../providerInstances"; import { @@ -57,7 +57,6 @@ export function SourceControlWritingSettingsSection() { const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders, { providersLoaded: providerSnapshotsLoaded, }); - const hasTextGenerationProvider = !isNoProviderModelSelection(defaultModelSelection); const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection( settings, @@ -70,6 +69,10 @@ export function SourceControlWritingSettingsSection() { const instanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ).filter((entry) => entry.supportsTextGeneration); + const hasTextGenerationProvider = hasSelectableTextGenerationProviderSelection( + defaultModelSelection, + instanceEntries, + ); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, serverProviders, @@ -181,7 +184,7 @@ export function SourceControlWritingSettingsSection() { control={
{!providerSnapshotsLoaded ? ( - ) : usesDedicatedModel && hasTextGenerationProvider ? ( @@ -201,7 +204,7 @@ export function SourceControlWritingSettingsSection() { }} /> ) : !hasTextGenerationProvider ? ( - ) : null} @@ -214,6 +217,7 @@ export function SourceControlWritingSettingsSection() { const nextSelection = resolveSourceControlWriterToggleSelection( Boolean(checked), defaultModelSelection, + hasTextGenerationProvider, ); if (nextSelection === undefined) return; updateSettings({ sourceControlWriterModelSelection: nextSelection }); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 117450cf3a78..5f4900c0180c 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -5,13 +5,34 @@ import { deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, + hasSelectableTextGenerationProviderSelection, isProviderInstancePickerReady, isProviderInstancePickerVisible, + NO_PROVIDER_MODEL_SELECTION, resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; +describe("hasSelectableTextGenerationProviderSelection", () => { + it("uses catalog presence so a legitimate empty-catalog instance cannot collide", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "t3code_no_provider", + models: [], + }), + ]); + + expect(hasSelectableTextGenerationProviderSelection(NO_PROVIDER_MODEL_SELECTION, [])).toBe( + false, + ); + expect(hasSelectableTextGenerationProviderSelection(NO_PROVIDER_MODEL_SELECTION, entries)).toBe( + true, + ); + }); +}); + function provider(input: { provider: ProviderDriverKind; instanceId: string; diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 4b0b2c117e14..9d7ca1132086 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -39,10 +39,6 @@ export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = { model: "", }; -export function isNoProviderModelSelection(selection: ModelSelection): boolean { - return selection.instanceId === NO_PROVIDER_MODEL_SELECTION.instanceId; -} - /** * UI-facing projection of one configured provider instance. Carries the * snapshot verbatim for callers that need server-side fields we don't @@ -71,6 +67,24 @@ export interface ProviderInstanceEntry { readonly models: ReadonlyArray; } +/** + * Whether a resolved selection names a real enabled text-generation instance. + * Local empty-state values are intentionally identified through catalog + * absence, not a reserved string pair that a configured instance could use. + */ +export function hasSelectableTextGenerationProviderSelection( + selection: ModelSelection, + entries: ReadonlyArray, +): boolean { + return entries.some( + (entry) => + entry.instanceId === selection.instanceId && + entry.enabled && + entry.isAvailable && + entry.supportsTextGeneration, + ); +} + /** * Whether an instance can currently contribute models to an interactive picker. * From 642c39522970cc3b5373ab94a6afeca125a34db5 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 23:15:28 -0400 Subject: [PATCH 4/8] fix(providers): align Kilo integration conventions --- apps/server/src/provider/Drivers/KiloDriver.ts | 2 +- apps/server/src/provider/Layers/KiloAdapter.ts | 2 +- apps/web/src/components/settings/SettingsPanels.tsx | 9 +++++---- .../settings/SourceControlWritingSettings.tsx | 10 +++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Drivers/KiloDriver.ts b/apps/server/src/provider/Drivers/KiloDriver.ts index bdc8b65664c0..41157ec7b017 100644 --- a/apps/server/src/provider/Drivers/KiloDriver.ts +++ b/apps/server/src/provider/Drivers/KiloDriver.ts @@ -150,7 +150,7 @@ export const KiloDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Kilo snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build Kilo snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index e7148e950bf6..ccaf29288166 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -1403,7 +1403,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: cause.message, + detail: "Failed to read attachment file.", cause, }), ), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6761d73935cd..5b2d9b363689 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -49,6 +49,7 @@ import { resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { ComposerControl } from "../chat/ComposerControl"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveEnvironmentIdentificationPillLabel, @@ -2428,9 +2429,9 @@ export function GeneralSettingsPanel() { control={
{!providerSnapshotsLoaded ? ( - + ) : hasTextGenerationProvider ? ( <> ) : ( - + )}
} diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 24e52cded43c..e8de82e44830 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -16,8 +16,8 @@ import { resolveAppModelSelectionState, } from "../../modelSelection"; import { primaryServerConfigAtom, primaryServerProvidersAtom } from "../../state/server"; +import { ComposerControl } from "../chat/ComposerControl"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; -import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; @@ -184,9 +184,9 @@ export function SourceControlWritingSettingsSection() { control={
{!providerSnapshotsLoaded ? ( - + ) : usesDedicatedModel && hasTextGenerationProvider ? ( ) : !hasTextGenerationProvider ? ( - + ) : null} Date: Sun, 23 Aug 2026 23:34:23 -0400 Subject: [PATCH 5/8] fix(web): keep provider settings state consistent --- .../components/settings/SettingsPanels.tsx | 22 +-- ...SourceControlWritingSettings.logic.test.ts | 115 ++++++++++++++- .../SourceControlWritingSettings.logic.ts | 13 ++ .../settings/SourceControlWritingSettings.tsx | 34 +++-- apps/web/src/providerInstances.test.ts | 134 ++++++++++++++++++ apps/web/src/providerInstances.ts | 23 ++- 6 files changed, 316 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5b2d9b363689..0b78b2c6ce2f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -74,7 +74,7 @@ import { withoutPlanAgentSelection, } from "../../modelSelection"; import { - applyProviderInstanceSettings, + applyProviderInstanceSettingsToSnapshots, deriveProviderInstanceEntries, hasSelectableTextGenerationProviderSelection, sortProviderInstanceEntries, @@ -1870,6 +1870,10 @@ export function GeneralSettingsPanel() { const serverConfig = useAtomValue(primaryServerConfigAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerSnapshotsLoaded = serverConfig !== null; + const settingsAwareServerProviders = applyProviderInstanceSettingsToSnapshots( + serverProviders, + settings, + ); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -1878,14 +1882,16 @@ export function GeneralSettingsPanel() { otlpMetricsUrl: observability?.otlpMetricsUrl, }); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders, { - providersLoaded: providerSnapshotsLoaded, - }); + const textGenerationModelSelection = resolveAppModelSelectionState( + settings, + settingsAwareServerProviders, + { providersLoaded: providerSnapshotsLoaded }, + ); const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + deriveProviderInstanceEntries(settingsAwareServerProviders), ).filter((entry) => entry.supportsTextGeneration); const hasTextGenerationProvider = hasSelectableTextGenerationProviderSelection( textGenerationModelSelection, @@ -1898,7 +1904,7 @@ export function GeneralSettingsPanel() { textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( settings, - serverProviders, + settingsAwareServerProviders, textGenInstanceId, textGenModel, ); @@ -2449,7 +2455,7 @@ export function GeneralSettingsPanel() { ...settings, textGenerationModelSelection: createModelSelection(instanceId, model), }, - serverProviders, + settingsAwareServerProviders, { providersLoaded: providerSnapshotsLoaded }, ), }); @@ -2483,7 +2489,7 @@ export function GeneralSettingsPanel() { nextOptions, ), }, - serverProviders, + settingsAwareServerProviders, { providersLoaded: providerSnapshotsLoaded }, ), }); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts index 0575adf53d2c..b802dda43914 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts +++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts @@ -1,8 +1,17 @@ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; import { describe, expect, it } from "vite-plus/test"; -import { NO_PROVIDER_MODEL_SELECTION } from "../../providerInstances"; -import { resolveSourceControlWriterToggleSelection } from "./SourceControlWritingSettings.logic"; +import { resolveAppModelSelectionState } from "../../modelSelection"; +import { + applyProviderInstanceSettingsToSnapshots, + NO_PROVIDER_MODEL_SELECTION, +} from "../../providerInstances"; +import { + resolveActiveSourceControlWriterSelection, + resolveSourceControlWriterToggleSelection, +} from "./SourceControlWritingSettings.logic"; describe("resolveSourceControlWriterToggleSelection", () => { it("copies an available default when enabling the override", () => { @@ -33,3 +42,103 @@ describe("resolveSourceControlWriterToggleSelection", () => { ).toBeNull(); }); }); + +describe("resolveActiveSourceControlWriterSelection", () => { + it("keeps a valid dedicated selection by exact identity", () => { + const dedicatedSelection = { + instanceId: ProviderInstanceId.make("dedicated_writer"), + model: "anthropic/dedicated-model", + }; + const defaultSelection = { + instanceId: ProviderInstanceId.make("general_writer"), + model: "openai/general-model", + }; + + expect( + resolveActiveSourceControlWriterSelection( + dedicatedSelection, + dedicatedSelection, + defaultSelection, + ), + ).toBe(dedicatedSelection); + }); + + it("uses the live general selection when settings-only fallbacks are unavailable", () => { + const globalInstanceId = ProviderInstanceId.make("disabled_global"); + const dedicatedInstanceId = ProviderInstanceId.make("disabled_writer"); + const missingFallbackInstanceId = ProviderInstanceId.make("missing_fallback"); + const liveInstanceId = ProviderInstanceId.make("live_writer"); + const codex = ProviderDriverKind.make("codex"); + const claude = ProviderDriverKind.make("claudeAgent"); + const cursor = ProviderDriverKind.make("cursor"); + const grok = ProviderDriverKind.make("grok"); + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providers: { + ...DEFAULT_UNIFIED_SETTINGS.providers, + codex: { ...DEFAULT_UNIFIED_SETTINGS.providers.codex, enabled: false }, + claudeAgent: { ...DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent, enabled: false }, + cursor: { ...DEFAULT_UNIFIED_SETTINGS.providers.cursor, enabled: false }, + grok: { ...DEFAULT_UNIFIED_SETTINGS.providers.grok, enabled: false }, + opencode: { ...DEFAULT_UNIFIED_SETTINGS.providers.opencode, enabled: false }, + kilo: { ...DEFAULT_UNIFIED_SETTINGS.providers.kilo, enabled: false }, + }, + providerInstances: { + [globalInstanceId]: { driver: codex, enabled: false }, + [dedicatedInstanceId]: { driver: claude, enabled: false }, + [missingFallbackInstanceId]: { driver: cursor, enabled: true }, + [liveInstanceId]: { driver: grok, enabled: true }, + }, + textGenerationModelSelection: { + instanceId: globalInstanceId, + model: "openai/disabled-global", + }, + sourceControlWriterModelSelection: { + instanceId: dedicatedInstanceId, + model: "anthropic/disabled-writer", + }, + } satisfies UnifiedSettings; + const providers = [ + { + instanceId: liveInstanceId, + driver: grok, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [ + { + slug: "xai/live-model", + name: "Live model", + isCustom: false, + capabilities: {}, + }, + ], + slashCommands: [], + skills: [], + } satisfies ServerProvider, + ]; + const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings); + const generalSelection = resolveAppModelSelectionState(settings, effectiveProviders); + const sourceControlSelection = resolveSourceControlWriterModelSelection( + settings, + effectiveProviders, + ); + + expect(sourceControlSelection).toMatchObject({ instanceId: missingFallbackInstanceId }); + expect(sourceControlSelection).not.toBe(settings.sourceControlWriterModelSelection); + expect(generalSelection).toMatchObject({ + instanceId: liveInstanceId, + model: "xai/live-model", + }); + expect( + resolveActiveSourceControlWriterSelection( + sourceControlSelection, + settings.sourceControlWriterModelSelection, + generalSelection, + ), + ).toBe(generalSelection); + }); +}); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts index 34605e8c02bd..b09ae5af2c67 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts +++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts @@ -18,3 +18,16 @@ export function resolveSourceControlWriterToggleSelection( defaultSelection.options, ); } + +/** + * Keep the dedicated selection only when the shared resolver returned the + * exact configured object. Any fallback it produced is settings-only, so the + * already live-resolved general selection remains authoritative in the UI. + */ +export function resolveActiveSourceControlWriterSelection( + resolvedSelection: ModelSelection, + dedicatedSelection: ModelSelection | null, + defaultSelection: ModelSelection, +): ModelSelection { + return resolvedSelection === dedicatedSelection ? resolvedSelection : defaultSelection; +} diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index e8de82e44830..c8260909ef1f 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -6,7 +6,7 @@ import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/server import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { - applyProviderInstanceSettings, + applyProviderInstanceSettingsToSnapshots, deriveProviderInstanceEntries, hasSelectableTextGenerationProviderSelection, sortProviderInstanceEntries, @@ -22,7 +22,10 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; -import { resolveSourceControlWriterToggleSelection } from "./SourceControlWritingSettings.logic"; +import { + resolveActiveSourceControlWriterSelection, + resolveSourceControlWriterToggleSelection, +} from "./SourceControlWritingSettings.logic"; const MODE_OPTIONS: Record = { @@ -48,26 +51,33 @@ export function SourceControlWritingSettingsSection() { const serverConfig = useAtomValue(primaryServerConfigAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerSnapshotsLoaded = serverConfig !== null; + const settingsAwareServerProviders = applyProviderInstanceSettingsToSnapshots( + serverProviders, + settings, + ); const customInstructionsRef = useRef(null); const style = settings.sourceControlWritingStyle; const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; const isSourceControlWritingStyleDirty = style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; - const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders, { - providersLoaded: providerSnapshotsLoaded, - }); + const defaultModelSelection = resolveAppModelSelectionState( + settings, + settingsAwareServerProviders, + { providersLoaded: providerSnapshotsLoaded }, + ); const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection( settings, - serverProviders, + settingsAwareServerProviders, + ); + const activeSelection = resolveActiveSourceControlWriterSelection( + resolvedSourceControlWriterSelection, + settings.sourceControlWriterModelSelection, + defaultModelSelection, ); - const activeSelection = - resolvedSourceControlWriterSelection === settings.textGenerationModelSelection - ? defaultModelSelection - : resolvedSourceControlWriterSelection; const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + deriveProviderInstanceEntries(settingsAwareServerProviders), ).filter((entry) => entry.supportsTextGeneration); const hasTextGenerationProvider = hasSelectableTextGenerationProviderSelection( defaultModelSelection, @@ -75,7 +85,7 @@ export function SourceControlWritingSettingsSection() { ); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, - serverProviders, + settingsAwareServerProviders, activeSelection.instanceId, activeSelection.model, ); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 5f4900c0180c..68a2bf8173c3 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -1,7 +1,9 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, + applyProviderInstanceSettingsToSnapshots, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, @@ -13,6 +15,7 @@ import { resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; +import { resolveAppModelSelectionState } from "./modelSelection"; describe("hasSelectableTextGenerationProviderSelection", () => { it("uses catalog presence so a legitimate empty-catalog instance cannot collide", () => { @@ -69,6 +72,137 @@ const model = (slug: string, isCustom = false, isDefault = false) => ({ capabilities: {}, }); +describe("applyProviderInstanceSettingsToSnapshots", () => { + it("uses an enabled setting while the streamed snapshot still reports disabled", () => { + const instanceId = ProviderInstanceId.make("t3code_no_provider"); + const driver = ProviderDriverKind.make("codex"); + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + ...DEFAULT_UNIFIED_SETTINGS.providerInstances, + [instanceId]: { driver, enabled: true }, + }, + textGenerationModelSelection: { instanceId, model: "openai/gpt-5.5" }, + }; + const providers = [ + provider({ + provider: driver, + instanceId, + enabled: false, + models: [model("openai/gpt-5.5")], + }), + ]; + + const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings); + expect(effectiveProviders[0]?.enabled).toBe(true); + expect(resolveAppModelSelectionState(settings, effectiveProviders)).toMatchObject({ + instanceId, + model: "openai/gpt-5.5", + }); + }); + + it("uses a disabled setting while the streamed snapshot still reports enabled", () => { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + ...DEFAULT_UNIFIED_SETTINGS.providerInstances, + [instanceId]: { driver, enabled: false }, + }, + textGenerationModelSelection: { instanceId, model: "openai/gpt-5.5" }, + }; + const providers = [ + provider({ + provider: driver, + instanceId, + enabled: true, + models: [model("openai/gpt-5.5")], + }), + ]; + + const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings); + expect(effectiveProviders[0]?.enabled).toBe(false); + expect(resolveAppModelSelectionState(settings, effectiveProviders)).toEqual( + NO_PROVIDER_MODEL_SELECTION, + ); + }); + + it("disables a stale snapshot when the same instance id was recreated under another driver", () => { + const instanceId = ProviderInstanceId.make("shared_work"); + const oldDriver = ProviderDriverKind.make("codex"); + const replacementDriver = ProviderDriverKind.make("claudeAgent"); + const oldSelection = { instanceId, model: "openai/old-catalog-model" }; + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + ...DEFAULT_UNIFIED_SETTINGS.providerInstances, + [instanceId]: { driver: replacementDriver, enabled: true }, + }, + textGenerationModelSelection: oldSelection, + }; + const providers = [ + provider({ + provider: oldDriver, + instanceId, + enabled: true, + models: [model(oldSelection.model)], + }), + ]; + + const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings); + expect(effectiveProviders[0]).toMatchObject({ driver: oldDriver, enabled: false }); + expect(resolveAppModelSelectionState(settings, effectiveProviders)).toEqual( + NO_PROVIDER_MODEL_SELECTION, + ); + expect(resolveDefaultProviderModelSelection(effectiveProviders, oldSelection)).toBeNull(); + }); + + it("keeps an unavailable snapshot disabled when settings enable it", () => { + const unavailableInstanceId = ProviderInstanceId.make("unavailable_writer"); + const liveInstanceId = ProviderInstanceId.make("live_writer"); + const codex = ProviderDriverKind.make("codex"); + const claude = ProviderDriverKind.make("claudeAgent"); + const unavailableSelection = { + instanceId: unavailableInstanceId, + model: "openai/unavailable-model", + }; + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [unavailableInstanceId]: { driver: codex, enabled: true }, + [liveInstanceId]: { driver: claude, enabled: true }, + }, + textGenerationModelSelection: unavailableSelection, + }; + const providers = [ + provider({ + provider: codex, + instanceId: unavailableInstanceId, + enabled: false, + availability: "unavailable", + models: [model(unavailableSelection.model)], + }), + provider({ + provider: claude, + instanceId: liveInstanceId, + models: [model("anthropic/live-model")], + }), + ]; + + const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings); + const entries = deriveProviderInstanceEntries(effectiveProviders); + const unavailableEntry = entries.find((entry) => entry.instanceId === unavailableInstanceId); + + expect(effectiveProviders[0]).toMatchObject({ availability: "unavailable", enabled: false }); + expect(unavailableEntry && isProviderInstancePickerVisible(unavailableEntry)).toBe(false); + expect(resolveAppModelSelectionState(settings, effectiveProviders)).toMatchObject({ + instanceId: liveInstanceId, + model: "anthropic/live-model", + }); + }); +}); + describe("isProviderInstancePickerReady", () => { it("rejects a disabled instance even while its last probe status is ready", () => { const [entry] = deriveProviderInstanceEntries([ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 9d7ca1132086..2a75e9702637 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -268,15 +268,34 @@ export function applyProviderInstanceSettings( return entries.map((entry) => { const explicitInstance = settings.providerInstances?.[entry.instanceId]; - const enabled = explicitInstance - ? resolveProviderInstanceEnabled(explicitInstance) + const configuredEnabled = explicitInstance + ? explicitInstance.driver === entry.driverKind && + resolveProviderInstanceEnabled(explicitInstance) : entry.isDefault ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled) : false; + const enabled = entry.isAvailable && configuredEnabled; return enabled === entry.enabled ? entry : { ...entry, enabled }; }); } +/** + * Apply settings-owned enablement to provider snapshots before selection logic + * consumes them. This keeps routing resolution and picker visibility on the + * same state while streamed probes reconcile an enable/disable settings write. + */ +export function applyProviderInstanceSettingsToSnapshots( + providers: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + return applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings).map( + (entry) => + entry.enabled === entry.snapshot.enabled + ? entry.snapshot + : { ...entry.snapshot, enabled: entry.enabled }, + ); +} + /** * Sort instance entries so the default instance of each driver kind appears * before any custom instances of the same kind. Within a kind, custom From 1cec4643c40754b14172cdcbf338a2eae38490e6 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 24 Aug 2026 00:25:51 -0400 Subject: [PATCH 6/8] fix(providers): close Kilo interruption races --- .../src/provider/Layers/KiloAdapter.test.ts | 338 ++++++++ .../server/src/provider/Layers/KiloAdapter.ts | 753 ++++++++++-------- .../ProviderInstanceRegistryLive.test.ts | 56 ++ .../Layers/ProviderInstanceRegistryLive.ts | 15 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 71 ++ .../src/provider/acp/AcpSessionRuntime.ts | 85 +- 6 files changed, 926 insertions(+), 392 deletions(-) diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 11f0952ccb85..e2b8edfb9e1c 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -8,6 +8,7 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -1508,6 +1509,85 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("quarantines owned cancellation when the interrupt caller is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-interrupt-caller-interrupted"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-interrupt-owner-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const cancelTransportStarted = yield* Deferred.make(); + const holdCancelTransport = yield* Deferred.make(); + const cancellationStarter: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime( + { + ...input, + beforeCancelTransportWrite: Deferred.succeed(cancelTransportStarted, undefined).pipe( + Effect.andThen(Deferred.await(holdCancelTransport)), + ), + }, + configureRuntime, + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime: cancellationStarter, + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "interrupt cancellation owner", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const interrupting = yield* adapter + .interruptTurn(threadId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cancelTransportStarted); + yield* Fiber.interrupt(interrupting); + const interruptExit = yield* Fiber.await(interrupting); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isFailure(interruptExit)); + if (Exit.isFailure(interruptExit)) { + assert.isTrue(Cause.hasInterrupts(interruptExit.cause)); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + const completed = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.equal(completed.length, 1); + if (completed[0]?.type === "turn.completed") { + assert.equal(completed[0].payload.state, "failed"); + } + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + yield* Fiber.interrupt(eventsFiber); + }), + ); + it.effect("quarantines the session when the remote prompt never settles after cancellation", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-cancel-settlement-timeout"); @@ -1942,6 +2022,102 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect( + "releases an interrupted prompt claim before the next queued send acquires the lock", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-send-interrupted-after-claim"); + const wrapperPath = yield* Effect.promise(() => makeMockKiloWrapper()); + const baseThreadLocks = yield* makeKiloThreadLockRegistry; + const nextSendRegistered = yield* Deferred.make(); + let observeNextRegistration = false; + const threadLocks: KiloThreadLockRegistry = { + withLock: (registeredThreadId, effect) => + Effect.suspend(() => + (observeNextRegistration + ? Deferred.succeed(nextSendRegistered, undefined).pipe(Effect.ignore) + : Effect.void + ).pipe(Effect.andThen(baseThreadLocks.withLock(registeredThreadId, effect))), + ), + activeKeyCount: baseThreadLocks.activeKeyCount, + activeUserCount: baseThreadLocks.activeUserCount, + }; + const firstPromptClaimed = yield* Deferred.make(); + const holdFirstPrompt = yield* Deferred.make(); + const secondPromptClaimed = yield* Deferred.make(); + const shouldHoldFirstPrompt = yield* Ref.make(true); + const adapter = yield* makeTestAdapter(wrapperPath, { + threadLockRegistry: threadLocks, + afterPromptClaim: (turnId) => + Ref.getAndSet(shouldHoldFirstPrompt, false).pipe( + Effect.flatMap((shouldHold) => + shouldHold + ? Deferred.succeed(firstPromptClaimed, turnId).pipe( + Effect.andThen(Deferred.await(holdFirstPrompt)), + ) + : Deferred.succeed(secondPromptClaimed, turnId).pipe(Effect.ignore), + ), + ), + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const secondPromptCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(secondPromptCompleted, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstSend = yield* adapter + .sendTurn({ threadId, input: "interrupt after prompt claim", attachments: [] }) + .pipe(Effect.forkChild({ startImmediately: true })); + const firstTurnId = yield* Deferred.await(firstPromptClaimed); + observeNextRegistration = true; + const secondSend = yield* adapter + .sendTurn({ + threadId, + input: "a fresh prompt must not steer the interrupted claim", + attachments: [], + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(nextSendRegistered); + assert.isFalse(yield* Deferred.isDone(secondPromptClaimed)); + yield* Fiber.interrupt(firstSend); + const firstExit = yield* Fiber.await(firstSend); + const secondTurnId = yield* Deferred.await(secondPromptClaimed); + const second = yield* Fiber.join(secondSend); + yield* Deferred.await(secondPromptCompleted); + + assert.isTrue(Exit.isFailure(firstExit)); + if (Exit.isFailure(firstExit)) { + assert.isTrue(Cause.hasInterrupts(firstExit.cause)); + } + assert.notEqual(secondTurnId, firstTurnId); + assert.equal(second.turnId, secondTurnId); + const readySession = (yield* adapter.listSessions())[0]; + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + const started = runtimeEvents.filter((event) => event.type === "turn.started"); + const completed = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.equal(started.length, 1); + assert.equal(completed.length, 1); + assert.equal(started[0]?.turnId, secondTurnId); + assert.equal(completed[0]?.turnId, secondTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("closes the ACP child process when a session stops", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-stop-session-close"); @@ -1975,6 +2151,56 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("finishes owned stop cleanup when the stop caller is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-caller-interrupted"); + const wrapperPath = yield* Effect.promise(() => makeMockKiloWrapper()); + const stopClaimed = yield* Deferred.make(); + const releaseStopCleanup = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + afterSessionStopClaim: () => + Deferred.succeed(stopClaimed, undefined).pipe( + Effect.andThen(Deferred.await(releaseStopCleanup)), + ), + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const stopping = yield* adapter + .stopSession(threadId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(stopClaimed); + assert.isFalse(yield* adapter.hasSession(threadId)); + const interrupting = yield* Fiber.interrupt(stopping).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStopCleanup, undefined); + yield* Fiber.join(interrupting); + yield* Deferred.await(sessionExited); + + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + yield* adapter.stopAll(); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + yield* Fiber.interrupt(eventsFiber); + }), + ); + it.effect("stopAll cancels a session startup already in progress", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-stop-all-during-start"); @@ -2019,6 +2245,118 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("closes provisional startup ownership when interrupted before startup events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-start-interrupted-before-events"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-start-event-interrupt-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath }), + ); + const startupRegistered = yield* Deferred.make(); + const holdStartupEvents = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + beforeStartupEvents: () => + Deferred.succeed(startupRegistered, undefined).pipe( + Effect.andThen(Deferred.await(holdStartupEvents)), + ), + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + const starting = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(startupRegistered); + assert.isTrue(yield* adapter.hasSession(threadId)); + yield* Fiber.interrupt(starting); + const startExit = yield* Fiber.await(starting); + + assert.isTrue(Exit.isFailure(startExit)); + if (Exit.isFailure(startExit)) { + assert.isTrue(Cause.hasInterrupts(startExit.cause)); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.equal(runtimeEvents.filter((event) => event.type === "session.started").length, 0); + assert.equal(runtimeEvents.filter((event) => event.type === "thread.started").length, 0); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("publishes no startup lifecycle when a later event stamp cannot be generated", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-start-event-stamp-failure"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-start-stamp-failure-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath }), + ); + const crypto = yield* Crypto.Crypto; + let failStartupEventStamps = false; + let startupEventStampCalls = 0; + const failingCrypto = Crypto.make({ + randomBytes: (size) => { + if (failStartupEventStamps) { + startupEventStampCalls += 1; + if (startupEventStampCalls === 2) { + throw new Error("injected startup event stamp failure"); + } + } + return globalThis.crypto.getRandomValues(new Uint8Array(size)); + }, + digest: (algorithm, data) => crypto.digest(algorithm, data), + }); + const adapter = yield* makeTestAdapter(wrapperPath, { + beforeStartupEvents: () => + Effect.sync(() => { + failStartupEventStamps = true; + startupEventStampCalls = 0; + }), + }).pipe(Effect.provideService(Crypto.Crypto, failingCrypto)); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + const startExit = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(startExit)); + assert.equal(startupEventStampCalls, 2); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.equal(runtimeEvents.filter((event) => event.type === "session.started").length, 0); + assert.equal( + runtimeEvents.filter((event) => event.type === "session.state.changed").length, + 0, + ); + assert.equal(runtimeEvents.filter((event) => event.type === "thread.started").length, 0); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + + yield* Fiber.interrupt(eventsFiber); + }), + ); + it.effect("serializes stopSession with stopAll and emits one terminal session event", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-stop-all-stop-session-race"); diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index ccaf29288166..2fe8d12f39b1 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -212,6 +212,12 @@ export interface KiloAdapterLiveOptions { readonly afterApprovalClaim?: (requestId: ApprovalRequestId) => Effect.Effect; /** Optional synchronization hook after prompt settlement acquires the thread lock. */ readonly beforePromptSettlement?: (turnId: TurnId) => Effect.Effect; + /** Optional synchronization hook after a prompt claim. Used by focused interruption tests. */ + readonly afterPromptClaim?: (turnId: TurnId) => Effect.Effect; + /** Optional synchronization hook after a session stop claim. Used by focused interruption tests. */ + readonly afterSessionStopClaim?: (threadId: ThreadId) => Effect.Effect; + /** Optional synchronization hook before startup events publish. Used by focused interruption tests. */ + readonly beforeStartupEvents?: (threadId: ThreadId) => Effect.Effect; /** Overrides ACP runtime startup. Used by focused adapter-boundary tests. */ readonly startAcpRuntime?: typeof startKiloAcpRuntime; } @@ -698,41 +704,46 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( }; const stopSessionInternal = Effect.fn("stopSessionInternal")(function* (ctx: KiloSessionContext) { - if (ctx.stopped) return; - const stoppedTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if ( - stoppedTurnId !== undefined && - (ctx.session.status === "running" || ctx.session.status === "connecting") - ) { - // Preserve queued deltas/tool updates ahead of the terminal event. - yield* Effect.ignore(ctx.acp.drainEvents); - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: stoppedTurnId, - payload: { state: "cancelled", stopReason: "cancelled" }, - }); - } - ctx.promptsInFlight = 0; - ctx.activeTurnId = undefined; - const { activeTurnId: _activeTurnId, ...stoppedSession } = ctx.session; - ctx.session = { ...stoppedSession, status: "ready", updatedAt: yield* nowIso }; - ctx.stopped = true; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - if (ctx.notificationFiber) { - yield* Fiber.interrupt(ctx.notificationFiber); - } - yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); - sessions.delete(ctx.threadId); - yield* offerRuntimeEvent({ - type: "session.exited", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: ctx.threadId, - payload: { exitKind: "graceful" }, - }); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if (ctx.stopped) return; + const stoppedTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if ( + stoppedTurnId !== undefined && + (ctx.session.status === "running" || ctx.session.status === "connecting") + ) { + // Preserve queued deltas/tool updates ahead of the terminal event. + yield* restore(Effect.ignore(ctx.acp.drainEvents)); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: stoppedTurnId, + payload: { state: "cancelled", stopReason: "cancelled" }, + }); + } + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...stoppedSession } = ctx.session; + ctx.session = { ...stoppedSession, status: "ready", updatedAt: yield* nowIso }; + ctx.stopped = true; + yield* options?.afterSessionStopClaim?.(ctx.threadId) ?? Effect.void; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }), + ); }); const quarantineSessionInternal = Effect.fn("quarantineSessionInternal")(function* ( @@ -864,11 +875,17 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( const pendingApprovals = new Map(); const sessionApprovedPermissionFingerprints = new Set(); const sessionScope = yield* Scope.make("sequential"); + let ctx!: KiloSessionContext; let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + sessionScopeTransferred + ? Effect.void + : Effect.sync(() => { + if (sessions.get(input.threadId) === ctx) { + sessions.delete(input.threadId); + } + }).pipe(Effect.andThen(Scope.close(sessionScope, Exit.void))), ); - let ctx!: KiloSessionContext; const resumeSessionId = parseKiloResume(input.resumeCursor)?.sessionId; const acpNativeLoggers = makeAcpNativeLoggers({ @@ -1243,29 +1260,37 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( yield* ensureSessionStartIsCurrent(input.threadId, startGeneration); ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; + yield* options?.beforeStartupEvents?.(input.threadId) ?? Effect.void; + const sessionStartedStamp = yield* makeEventStamp(); + const sessionReadyStamp = yield* makeEventStamp(); + const threadStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { state: "ready", reason: "Kilo ACP session ready" }, - }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* offerRuntimeEvent({ + type: "session.started", + ...sessionStartedStamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...sessionReadyStamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Kilo ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...threadStartedStamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + sessionScopeTransferred = true; + }), + ); return session; }).pipe(Effect.scoped), @@ -1340,321 +1365,345 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( }); const sendTurn: KiloAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const normalizedText = input.input?.trim(); - if (!normalizedText && (input.attachments?.length ?? 0) === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", - }); - } - const prepared = yield* withThreadLock( - input.threadId, + return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer. Bind and - // count it before any cooperative yield so concurrent clients see - // one shared active turn. - const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); - ctx.promptsInFlight += 1; - ctx.activeTurnId = turnId; - ctx.session = { - ...ctx.session, - status: steeringTurnId === undefined ? "connecting" : "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - return yield* Effect.gen(function* () { - const turnModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const model = turnModelSelection?.model ?? ctx.session.model; - const configuredModel = yield* applyRequestedSessionConfiguration({ - runtime: ctx.acp, - threadId: input.threadId, - runtimeMode: ctx.session.runtimeMode, - interactionMode: input.interactionMode, - requestedModelId: model, - supervisedModeId: ctx.supervisedModeId, - planModeId: ctx.planModeId, + const normalizedText = input.input?.trim(); + if (!normalizedText && (input.attachments?.length ?? 0) === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", }); - const activeModel = configuredModel ?? model; + } + const promptSettled = yield* Ref.make(false); + const promptFailureMessage = yield* Ref.make(undefined); + const prepared = yield* restore( + withThreadLock( + input.threadId, + Effect.uninterruptibleMask((restorePreparation) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer. Bind and + // count it before any cooperative yield so concurrent clients see + // one shared active turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + const updatedAt = yield* nowIso; + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt, + }; + + return yield* restorePreparation( + Effect.gen(function* () { + yield* options?.afterPromptClaim?.(turnId) ?? Effect.void; + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const configuredModel = yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + threadId: input.threadId, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + requestedModelId: model, + supervisedModeId: ctx.supervisedModeId, + planModeId: ctx.planModeId, + }); + const activeModel = configuredModel ?? model; + + const promptParts: Array = []; + if (normalizedText) { + promptParts.push({ type: "text", text: normalizedText }); + } + for (const attachment of input.attachments ?? []) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Failed to read attachment file.", + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } - const promptParts: Array = []; - if (normalizedText) { - promptParts.push({ type: "text", text: normalizedText }); - } - for (const attachment of input.attachments ?? []) { - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - const bytes = yield* fileSystem.readFile(attachmentPath).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: "Failed to read attachment file.", - cause, + // Give an interrupt that observed this preparation a chance to + // mark the turn before the ACP prompt begins. + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kilo prompt was interrupted during preparation.", + }); + } + + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: activeModel }, + }); + } + ctx.session = { + ...ctx.session, + model: activeModel, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return { acp: ctx.acp, ctx, model: activeModel, promptParts, turnId }; }), - ), - ); - promptParts.push({ - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }); - } + ).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : releasePreparedPrompt(ctx, turnId), + ), + ); + }), + ), + ), + ); - // Give an interrupt that observed this preparation a chance to - // mark the turn before the ACP prompt begins. - for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - if (ctx.interruptedTurnIds.has(turnId)) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: "Kilo prompt was interrupted during preparation.", - }); - } + return yield* restore( + Effect.gen(function* () { + const result = yield* prepared.acp + .prompt( + { prompt: prepared.promptParts }, + { + shouldStart: Effect.sync(() => { + const ctx = sessions.get(input.threadId); + return ( + ctx === prepared.ctx && + !ctx.stopped && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId && + ctx.promptsInFlight > 0 && + !ctx.interruptedTurnIds.has(prepared.turnId) + ); + }), + }, + ) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + Effect.tapError((error) => Ref.set(promptFailureMessage, error.message)), + ); - if (steeringTurnId === undefined) { - ctx.lastPlanFingerprint = undefined; - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: activeModel }, - }); - } - ctx.session = { - ...ctx.session, - model: activeModel, - status: "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - return { acp: ctx.acp, ctx, model: activeModel, promptParts, turnId }; - }).pipe(Effect.tapCause(() => releasePreparedPrompt(ctx, turnId))); - }), - ); + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + yield* options?.beforePromptSettlement?.(prepared.turnId) ?? Effect.void; + const ctx = sessions.get(input.threadId); + if (ctx !== prepared.ctx || ctx.stopped) { + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kilo session changed before the turn completed.", + }); + } + + // The event stream carries a barrier acknowledged by the adapter + // consumer. Drain it while holding the thread lock so final tool + // updates and deltas retain this turn id and land before completion + // or any next-turn preparation. + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + // interruptTurn owns terminal settlement once it marks this turn. + // In particular, a locally interrupted prompt must not expose the + // session as ready before remote cancellation is confirmed. + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + if ( + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId || + ctx.promptsInFlight <= 0 + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } - const promptSettled = yield* Ref.make(false); - const promptFailureMessage = yield* Ref.make(undefined); - - return yield* Effect.gen(function* () { - const result = yield* prepared.acp - .prompt( - { prompt: prepared.promptParts }, - { - shouldStart: Effect.sync(() => { - const ctx = sessions.get(input.threadId); - return ( - ctx === prepared.ctx && - !ctx.stopped && - ctx.activeTurnId === prepared.turnId && - ctx.session.activeTurnId === prepared.turnId && - ctx.promptsInFlight > 0 && - !ctx.interruptedTurnIds.has(prepared.turnId) + const turnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId); + const promptSummary = { + textBlockCount: prepared.promptParts.filter((part) => part.type === "text") + .length, + imageBlockCount: prepared.promptParts.filter((part) => part.type === "image") + .length, + }; + if (turnRecord) { + turnRecord.items.push({ prompt: promptSummary, result }); + } else { + ctx.turns.push({ + id: prepared.turnId, + items: [{ prompt: promptSummary, result }], + }); + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + model: prepared.model, + }; + yield* settlePromptInFlight(ctx, prepared.turnId, { + stopReason: result.stopReason ?? null, + }); + yield* Ref.set(promptSettled, true); + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }), + ).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) return; + const errorMessage = yield* Ref.get(promptFailureMessage); + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = sessions.get(input.threadId); + if (ctx !== prepared.ctx || ctx.stopped) return; + yield* Effect.ignore(prepared.acp.drainEvents); + yield* settlePromptInFlight(ctx, prepared.turnId, { + errorMessage: errorMessage ?? "Kilo prompt request failed or was interrupted.", + }); + }), ); - }), - }, - ) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + }).pipe(Effect.catch(() => Effect.void)), ), - Effect.tapError((error) => Ref.set(promptFailureMessage, error.message)), ); + }), + ); + }); - return yield* withThreadLock( - input.threadId, + const interruptTurn: KiloAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - yield* options?.beforePromptSettlement?.(prepared.turnId) ?? Effect.void; - const ctx = sessions.get(input.threadId); - if (ctx !== prepared.ctx || ctx.stopped) { - yield* Ref.set(promptSettled, true); - return yield* new ProviderAdapterRequestError({ + // Preparation holds the thread lock, so record cancellation intent for + // that exact connecting turn before waiting. The send path observes this + // marker before it can enter ACP. Running turns are claimed only under the + // lock below so an old turn id can never acquire a newer turn's runtime. + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) return undefined; + const interruptedTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && interruptedTurnId !== turnId) { + return { ctx, interruptedTurnId, matchesRequestedTurn: false as const }; + } + if (interruptedTurnId !== undefined && ctx.session.status === "connecting") { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { ctx, interruptedTurnId, matchesRequestedTurn: true as const }; + }); + if (!observed) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - method: "session/prompt", - detail: "Kilo session changed before the turn completed.", - }); - } - - // The event stream carries a barrier acknowledged by the adapter - // consumer. Drain it while holding the thread lock so final tool - // updates and deltas retain this turn id and land before completion - // or any next-turn preparation. - yield* prepared.acp.drainEvents; - if (ctx.interruptedTurnIds.has(prepared.turnId)) { - // interruptTurn owns terminal settlement once it marks this turn. - // In particular, a locally interrupted prompt must not expose the - // session as ready before remote cancellation is confirmed. - yield* Ref.set(promptSettled, true); - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - } - if ( - ctx.activeTurnId !== prepared.turnId || - ctx.session.activeTurnId !== prepared.turnId || - ctx.promptsInFlight <= 0 - ) { - yield* Ref.set(promptSettled, true); - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - } - - const turnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId); - const promptSummary = { - textBlockCount: prepared.promptParts.filter((part) => part.type === "text").length, - imageBlockCount: prepared.promptParts.filter((part) => part.type === "image").length, - }; - if (turnRecord) { - turnRecord.items.push({ prompt: promptSummary, result }); - } else { - ctx.turns.push({ - id: prepared.turnId, - items: [{ prompt: promptSummary, result }], + threadId, }); } - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: prepared.turnId, - updatedAt: yield* nowIso, - model: prepared.model, - }; - yield* settlePromptInFlight(ctx, prepared.turnId, { - stopReason: result.stopReason ?? null, - }); - yield* Ref.set(promptSettled, true); - - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - }), - ); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - if (yield* Ref.get(promptSettled)) return; - const errorMessage = yield* Ref.get(promptFailureMessage); - yield* withThreadLock( - input.threadId, + if (!observed.matchesRequestedTurn || observed.interruptedTurnId === undefined) return; + const observedTurnId = observed.interruptedTurnId; + const cancellationClaim = yield* withThreadLock( + threadId, Effect.gen(function* () { - const ctx = sessions.get(input.threadId); - if (ctx !== prepared.ctx || ctx.stopped) return; - yield* Effect.ignore(prepared.acp.drainEvents); - yield* settlePromptInFlight(ctx, prepared.turnId, { - errorMessage: errorMessage ?? "Kilo prompt request failed or was interrupted.", - }); + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (ctx !== observed.ctx || activeTurnId !== observedTurnId) { + return undefined; + } + ctx.interruptedTurnIds.add(observedTurnId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + return { ctx, interruptedTurnId: observedTurnId }; }), ); - }).pipe(Effect.catch(() => Effect.void)), - ), - ); - }); - - const interruptTurn: KiloAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, turnId) { - // Preparation holds the thread lock, so record cancellation intent for - // that exact connecting turn before waiting. The send path observes this - // marker before it can enter ACP. Running turns are claimed only under the - // lock below so an old turn id can never acquire a newer turn's runtime. - const observed = yield* Effect.sync(() => { - const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) return undefined; - const interruptedTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (turnId !== undefined && interruptedTurnId !== turnId) { - return { ctx, interruptedTurnId, matchesRequestedTurn: false as const }; - } - if (interruptedTurnId !== undefined && ctx.session.status === "connecting") { - ctx.interruptedTurnIds.add(interruptedTurnId); - } - return { ctx, interruptedTurnId, matchesRequestedTurn: true as const }; - }); - if (!observed) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); - } - if (!observed.matchesRequestedTurn || observed.interruptedTurnId === undefined) return; - const observedTurnId = observed.interruptedTurnId; - const cancellationClaim = yield* withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (ctx !== observed.ctx || activeTurnId !== observedTurnId) { - return undefined; + if (!cancellationClaim) return; + const cancelTarget = cancellationClaim.ctx; + + // Release the preparation lock while awaiting the remote cancellation. + // A permission handler already inside logging/fingerprinting can then + // acquire the lock, observe the interrupted turn, and answer cancelled + // so the remote prompt is able to settle. Caller interruption is restored + // only for this remote operation; either outcome returns to protected + // lifecycle cleanup before the interrupt can escape. + const cancelExit = yield* restore( + cancelTarget.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ).pipe(Effect.exit); + if (Exit.isFailure(cancelExit)) { + yield* withThreadLock( + threadId, + Effect.gen(function* () { + if (sessions.get(threadId) !== cancelTarget || cancelTarget.stopped) return; + yield* quarantineSessionInternal( + cancelTarget, + "Kilo cancellation could not be confirmed. The session was terminated to prevent overlapping remote work.", + ); + }), + ); + return yield* Effect.failCause(cancelExit.cause); } - ctx.interruptedTurnIds.add(observedTurnId); - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - return { ctx, interruptedTurnId: observedTurnId }; - }), - ); - if (!cancellationClaim) return; - const cancelTarget = cancellationClaim.ctx; - - // Release the preparation lock while awaiting the remote cancellation. - // A permission handler already inside logging/fingerprinting can then - // acquire the lock, observe the interrupted turn, and answer cancelled - // so the remote prompt is able to settle. - const cancelExit = yield* cancelTarget.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - Effect.exit, - ); - if (Exit.isFailure(cancelExit)) { - yield* withThreadLock( - threadId, - Effect.uninterruptible( + + yield* withThreadLock( + threadId, Effect.gen(function* () { if (sessions.get(threadId) !== cancelTarget || cancelTarget.stopped) return; - yield* quarantineSessionInternal( - cancelTarget, - "Kilo cancellation could not be confirmed. The session was terminated to prevent overlapping remote work.", - ); + yield* Effect.ignore(cancelTarget.acp.drainEvents); + yield* settlePromptInFlight(cancelTarget, cancellationClaim.interruptedTurnId, { + stopReason: "cancelled", + settleAllPrompts: true, + }); }), - ), - ); - return yield* Effect.failCause(cancelExit.cause); - } - - yield* withThreadLock( - threadId, - Effect.gen(function* () { - if (sessions.get(threadId) !== cancelTarget || cancelTarget.stopped) return; - if (cancellationClaim.interruptedTurnId !== undefined) { - yield* Effect.ignore(cancelTarget.acp.drainEvents); - yield* settlePromptInFlight(cancelTarget, cancellationClaim.interruptedTurnId, { - stopReason: "cancelled", - settleAllPrompts: true, - }); - } + ); }), ); }, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a8790b9d0ceb..1d2f476f43da 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -38,6 +38,9 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -52,6 +55,8 @@ import { KiloDriver } from "../Drivers/KiloDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import type { ProviderDriver } from "../ProviderDriver.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; const TestHttpClientLive = Layer.succeed( @@ -142,6 +147,57 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +it.effect("logs provider creation causes without exposing them in unavailable snapshots", () => { + const logs: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + const instanceId = ProviderInstanceId.make("kilo_diagnostic_failure"); + const driverKind = ProviderDriverKind.make("kilo-diagnostic-test"); + const underlyingCause = new Error("private snapshot diagnostics"); + const stableDetail = "Failed to build Kilo snapshot."; + const failingDriver = { + driverKind, + metadata: { displayName: "Kilo diagnostic test" }, + configSchema: Schema.Struct({}), + defaultConfig: () => ({}), + create: () => + Effect.fail( + new ProviderDriverError({ + driver: driverKind, + instanceId, + detail: stableDetail, + cause: underlyingCause, + }), + ), + } satisfies ProviderDriver<{}>; + + return Effect.scoped( + Effect.gen(function* () { + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [failingDriver], + configMap: { + [instanceId]: { + driver: driverKind, + config: {}, + }, + }, + }); + + const [unavailable] = yield* registry.listUnavailable; + expect(unavailable?.unavailableReason).toContain(stableDetail); + expect(unavailable?.unavailableReason).not.toContain(underlyingCause.message); + expect(logs.some((log) => log.annotations.cause === underlyingCause)).toBe(true); + }), + ).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index fb75652e3856..00aaaff9010c 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -185,11 +185,16 @@ const buildEntry = (input: { }) .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); if (createResult._tag === "Failure") { - yield* Effect.logError("Failed to create provider instance", { - instanceId: rawInstanceId, - driver: entry.driver, - detail: createResult.failure.detail, - }); + yield* Effect.logError("Failed to create provider instance").pipe( + Effect.annotateLogs({ + instanceId: rawInstanceId, + driver: entry.driver, + detail: createResult.failure.detail, + ...(createResult.failure.cause === undefined + ? {} + : { cause: createResult.failure.cause }), + }), + ); yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); return { kind: "unavailable" as const, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 4107e37b3afd..87d7d75277ff 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -305,6 +305,77 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); }); + it.effect("cleans up an interrupted prompt registration before the next prompt starts", () => { + return Effect.gen(function* () { + const registrationReached = yield* Deferred.make(); + const firstPromptRpcStarted = yield* Deferred.make(); + const firstPromptRpcExited = yield* Deferred.make(); + let activePromptRpcs = 0; + let maximumActivePromptRpcs = 0; + let blockNextRegistration = true; + let blockNextPromptRpc = true; + const promptRequestStatuses: Array = + []; + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + beforePromptFiberRegistration: Effect.suspend(() => { + if (!blockNextRegistration) return Effect.void; + blockNextRegistration = false; + return Deferred.succeed(registrationReached, undefined).pipe( + Effect.andThen(Effect.never), + ); + }), + onPromptRpcFiberExit: Effect.sync(() => { + activePromptRpcs -= 1; + }).pipe(Effect.andThen(Deferred.succeed(firstPromptRpcExited, undefined)), Effect.asVoid), + requestLogger: (event) => + event.method !== "session/prompt" + ? Effect.void + : Effect.suspend(() => { + const shouldBlock = event.status === "started" && blockNextPromptRpc; + if (shouldBlock) blockNextPromptRpc = false; + return Effect.sync(() => { + promptRequestStatuses.push(event.status); + if (event.status === "started") { + activePromptRpcs += 1; + maximumActivePromptRpcs = Math.max(maximumActivePromptRpcs, activePromptRpcs); + } + }).pipe( + Effect.andThen( + event.status === "started" + ? Deferred.succeed(firstPromptRpcStarted, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(shouldBlock ? Effect.never : Effect.void), + ); + }), + }); + yield* runtime.start(); + + const firstPrompt = yield* runtime + .prompt({ prompt: [{ type: "text", text: "interrupt registration" }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(registrationReached); + yield* Deferred.await(firstPromptRpcStarted); + yield* Fiber.interrupt(firstPrompt); + yield* Deferred.await(firstPromptRpcExited); + + expect(activePromptRpcs).toBe(0); + expect(yield* runtime.prompt({ prompt: [{ type: "text", text: "second" }] })).toMatchObject({ + stopReason: "end_turn", + }); + expect(maximumActivePromptRpcs).toBe(1); + expect(activePromptRpcs).toBe(0); + expect(promptRequestStatuses).toEqual(["started", "started", "succeeded"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + }); + it.effect("fails cancellation when the active prompt does not acknowledge it in time", () => { return Effect.gen(function* () { const promptFiberCreated = yield* Deferred.make(); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 4a23432f4d7b..2b47bbf48b5e 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -74,6 +74,8 @@ export interface AcpSessionRuntimeOptions { readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; /** Optional prompt-lifecycle instrumentation used by deterministic runtime tests. */ readonly beforePromptFiberRegistration?: Effect.Effect; + /** Optional prompt-fiber exit instrumentation used by deterministic runtime tests. */ + readonly onPromptRpcFiberExit?: Effect.Effect; /** Maximum time allowed for the ACP `session/cancel` transport write. */ readonly cancelTransportTimeout?: Duration.Input; /** Optional cancellation-transport instrumentation used by deterministic runtime tests. */ @@ -761,41 +763,54 @@ export const make = ( sessionId: started.sessionId, ...payload, } satisfies EffectAcpSchema.PromptRequest; - const promptRpcFiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - yield* options.beforePromptFiberRegistration ?? Effect.void; - yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); - // Cancellation can land after the generation check above but - // before the active fiber is registered. Rechecking after the - // registration closes that window: cancellation either sees - // this fiber or this prompt observes the newer generation. - const registeredGeneration = yield* Ref.get(promptGenerationRef); - if (registeredGeneration !== queuedGeneration) { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* Ref.set(activePromptFiberRef, Option.none()); - return cancelledResponse; - } - return yield* Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - Effect.ensuring( - Effect.gen(function* () { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* Ref.set(activePromptFiberRef, Option.none()); - }), - ), - Effect.tap(() => - closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }), - ), + return yield* Effect.uninterruptibleMask((restorePromptLifecycle) => + Effect.gen(function* () { + const promptRpcFiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe( + Effect.ensuring(options.onPromptRpcFiberExit ?? Effect.void), + Effect.forkIn(runtimeScope), + ); + return yield* Effect.gen(function* () { + // The test-only gate remains interruptible, but its exit is + // already bracketed by the child cleanup below. Production + // registration stays masked between the fork and prompt join. + yield* restorePromptLifecycle( + options.beforePromptFiberRegistration ?? Effect.void, + ); + yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + // Cancellation can land after the generation check above but + // before the active fiber is registered. Rechecking after the + // registration closes that window: cancellation either sees + // this fiber or this prompt observes the newer generation. + const registeredGeneration = yield* Ref.get(promptGenerationRef); + if (registeredGeneration !== queuedGeneration) { + return cancelledResponse; + } + return yield* restorePromptLifecycle(Fiber.join(promptRpcFiber)).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), + ), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* Ref.set(activePromptFiberRef, Option.none()); + }), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), + ); + }), ); }), ); From ac606f73a621b8d58989124b2624ece11471a516 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 24 Aug 2026 01:05:14 -0400 Subject: [PATCH 7/8] fix(providers): bound Kilo stop cleanup --- .../src/provider/Layers/KiloAdapter.test.ts | 168 ++++++++++++++++++ .../server/src/provider/Layers/KiloAdapter.ts | 13 +- 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index e2b8edfb9e1c..5b445215593b 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -124,6 +124,16 @@ const kiloAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeKiloAdapter(decodeKiloSettings({ binaryPath }), options).pipe(Effect.orDie); +const makeDrainOverridingStarter = + (drainEvents: Effect.Effect): typeof startKiloAcpRuntime => + (input, configureRuntime) => + startKiloAcpRuntime(input, configureRuntime).pipe( + Effect.map(({ runtime, started }) => ({ + runtime: { ...runtime, drainEvents }, + started, + })), + ); + const runPermissionScenario = (input: { readonly name: string; readonly runtimeMode: "approval-required" | "auto-accept-edits"; @@ -2151,6 +2161,164 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("finishes stop cleanup before delivering interruption during event drain", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-interrupted-during-drain"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-stop-drain-interrupt-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const drainStarted = yield* Deferred.make(); + const releaseDrain = yield* Deferred.make(); + const drainFinalized = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime: makeDrainOverridingStarter( + Deferred.succeed(drainStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseDrain)), + Effect.ensuring(Deferred.succeed(drainFinalized, undefined)), + ), + ), + stopDrainTimeout: "10 seconds", + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined).pipe(Effect.ignore) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "stop during drain", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const stopping = yield* adapter + .stopSession(threadId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(drainStarted); + const interrupting = yield* Fiber.interrupt(stopping).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseDrain, undefined); + yield* Fiber.join(interrupting); + const stopExit = yield* Fiber.await(stopping); + yield* Deferred.await(drainFinalized); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isFailure(stopExit)); + if (Exit.isFailure(stopExit)) { + assert.isTrue(Cause.hasInterrupts(stopExit.cause)); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.isTrue(Exit.isFailure(yield* adapter.stopSession(threadId).pipe(Effect.exit))); + assert.isTrue( + Exit.isFailure( + yield* adapter + .sendTurn({ threadId, input: "must not reuse stopped child", attachments: [] }) + .pipe(Effect.exit), + ), + ); + yield* adapter.stopAll(); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("times out a hung event drain and still closes the owned session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-hung-event-drain"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-stop-drain-timeout-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const drainStarted = yield* Deferred.make(); + const drainFinalized = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime: makeDrainOverridingStarter( + Deferred.succeed(drainStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(drainFinalized, undefined)), + ), + ), + stopDrainTimeout: "1 second", + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined).pipe(Effect.ignore) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "hang the stop drain", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const stopping = yield* adapter.stopSession(threadId).pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(drainStarted); + yield* TestClock.adjust("1 second"); + const stopExit = yield* Fiber.join(stopping); + yield* Deferred.await(drainFinalized); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isSuccess(stopExit)); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + yield* adapter.stopAll(); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + yield* Fiber.interrupt(eventsFiber); + }), + ); + it.effect("finishes owned stop cleanup when the stop caller is interrupted", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-stop-caller-interrupted"); diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index 2fe8d12f39b1..16a4ab9b018e 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -90,6 +90,7 @@ const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; const ACP_READ_ONLY_MODE_ALIASES = ["ask"]; const KILO_USER_SESSION_STARTUP_TIMEOUT = Duration.seconds(30); +const KILO_STOP_EVENT_DRAIN_TIMEOUT = Duration.seconds(1); const KILO_PERMISSION_IDENTITY_MAX_BYTES = 256 * 1024; type KiloVersionCheckOutcome = @@ -206,6 +207,8 @@ export interface KiloAdapterLiveOptions { readonly resolveSettings?: Effect.Effect; /** Overrides the Kilo ACP readiness and initial-configuration deadline. */ readonly startupTimeout?: Duration.Input; + /** Overrides the bounded ACP event drain used by focused stop tests. */ + readonly stopDrainTimeout?: Duration.Input; /** Injects the keyed lifecycle lock registry for deterministic concurrency tests. */ readonly threadLockRegistry?: KiloThreadLockRegistry; /** Optional synchronization hook after an approval is claimed. Used by focused race tests. */ @@ -463,6 +466,9 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); const startAcpRuntime = options?.startAcpRuntime ?? startKiloAcpRuntime; + const stopDrainTimeout = Duration.fromInputUnsafe( + options?.stopDrainTimeout ?? KILO_STOP_EVENT_DRAIN_TIMEOUT, + ); const sessions = new Map(); const threadLocks = options?.threadLockRegistry ?? (yield* makeKiloThreadLockRegistry); @@ -713,7 +719,9 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( (ctx.session.status === "running" || ctx.session.status === "connecting") ) { // Preserve queued deltas/tool updates ahead of the terminal event. - yield* restore(Effect.ignore(ctx.acp.drainEvents)); + // Stop owns cleanup once it enters this region. A broken event + // consumer must not strand the child or session indefinitely. + yield* ctx.acp.drainEvents.pipe(Effect.timeoutOption(stopDrainTimeout), Effect.exit); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -742,6 +750,9 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( threadId: ctx.threadId, payload: { exitKind: "graceful" }, }); + // Preserve caller interruption semantics only after owned cleanup and + // terminal publication are complete. + yield* restore(Effect.void); }), ); }); From d07eefd002cd0a925bc5cc2889aa0726c5d70f67 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 24 Aug 2026 01:34:11 -0400 Subject: [PATCH 8/8] fix(providers): quiesce Kilo stop events --- .../src/provider/Layers/KiloAdapter.test.ts | 263 ++++++++++++++++++ .../server/src/provider/Layers/KiloAdapter.ts | 15 + 2 files changed, 278 insertions(+) diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 5b445215593b..6298eccfba80 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -36,6 +36,7 @@ import { import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderAdapterRequestError } from "../Errors.ts"; +import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; import { KILO_PROVIDER_DEFAULT_MODEL_ID, startKiloAcpRuntime } from "../acp/KiloAcpSupport.ts"; import { type KiloThreadLockRegistry, @@ -2234,6 +2235,13 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { assert.deepStrictEqual(yield* adapter.listSessions(), []); assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.isBelow( + runtimeEvents.findIndex( + (event) => + event.type === "content.delta" && event.payload.delta === "prompt reached mock", + ), + runtimeEvents.findIndex((event) => event.type === "turn.completed"), + ); assert.isTrue(Exit.isFailure(yield* adapter.stopSession(threadId).pipe(Effect.exit))); assert.isTrue( Exit.isFailure( @@ -2319,6 +2327,261 @@ it.layer(kiloAdapterTestLayer)("KiloAdapterLive", (it) => { }), ); + it.effect("quiesces post-barrier ACP output before completing a stopped turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-successful-drain-post-barrier-output"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-stop-post-barrier-output-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const releasePostBarrierEvent = yield* Deferred.make(); + const postBarrierMappingStarted = yield* Deferred.make(); + const releasePostBarrierMapping = yield* Deferred.make(); + const postBarrierMappingFinalized = yield* Deferred.make(); + const terminalSawQuiescedConsumer = yield* Ref.make(false); + const startAcpRuntime: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime(input, configureRuntime).pipe( + Effect.map(({ runtime, started }) => { + const postBarrierEvent: AcpSessionRuntimeEvent = { + _tag: "ContentDelta", + text: "late after successful drain", + rawPayload: { source: "post-barrier-regression" }, + }; + return { + started, + runtime: { + ...runtime, + drainEvents: runtime.drainEvents.pipe( + Effect.andThen(Deferred.succeed(releasePostBarrierEvent, undefined)), + Effect.andThen(Deferred.await(postBarrierMappingStarted)), + ), + getEvents: () => + Stream.merge( + runtime.getEvents(), + Stream.fromEffect(Deferred.await(releasePostBarrierEvent)).pipe( + Stream.map(() => postBarrierEvent), + ), + ), + }, + }; + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime, + stopDrainTimeout: "10 seconds", + nativeEventLogger: { + filePath: "memory://kilo-post-barrier-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("post-barrier-regression") + ? Deferred.succeed(postBarrierMappingStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePostBarrierMapping)), + Effect.ensuring(Deferred.succeed(postBarrierMappingFinalized, undefined)), + Effect.asVoid, + ) + : Effect.void, + close: () => Effect.void, + }, + afterStopTurnTerminal: () => + Deferred.isDone(postBarrierMappingFinalized).pipe( + Effect.flatMap((quiesced) => Ref.set(terminalSawQuiescedConsumer, quiesced)), + Effect.andThen(Deferred.succeed(releasePostBarrierMapping, undefined)), + ), + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined).pipe(Effect.ignore) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "stop after a successful drain", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const stopExit = yield* adapter.stopSession(threadId).pipe(Effect.exit); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isSuccess(stopExit)); + assert.isTrue(yield* Deferred.isDone(postBarrierMappingStarted)); + assert.isTrue(yield* Deferred.isDone(postBarrierMappingFinalized)); + assert.isTrue(yield* Ref.get(terminalSawQuiescedConsumer)); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed"); + assert.isAtLeast(completedIndex, 0); + assert.deepEqual( + runtimeEvents.slice(completedIndex + 1).filter((event) => event.turnId !== undefined), + [], + ); + assert.deepEqual( + runtimeEvents.filter( + (event) => + event.type === "content.delta" && event.payload.delta === "late after successful drain", + ), + [], + ); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("drops late ACP output after a stop drain timeout", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kilo-stop-drain-timeout-late-output"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kilo-stop-timeout-late-output-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiloWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_PROMPT_STARTED_BEFORE_HANG: "1", + }), + ); + const drainStarted = yield* Deferred.make(); + const drainFinalized = yield* Deferred.make(); + const releaseLateEvents = yield* Deferred.make(); + const lateEventsHandled = yield* Deferred.make(); + const startAcpRuntime: typeof startKiloAcpRuntime = (input, configureRuntime) => + startKiloAcpRuntime(input, configureRuntime).pipe( + Effect.map(({ runtime, started }) => { + const lateEvents: ReadonlyArray = [ + { + _tag: "ContentDelta", + text: "late after timed-out drain", + rawPayload: { source: "late-timeout-regression" }, + }, + { _tag: "EventStreamBarrier", acknowledge: lateEventsHandled }, + ]; + return { + started, + runtime: { + ...runtime, + drainEvents: Deferred.succeed(drainStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(drainFinalized, undefined)), + ), + getEvents: () => + Stream.merge( + runtime.getEvents(), + Stream.fromEffect(Deferred.await(releaseLateEvents)).pipe( + Stream.flatMap(() => Stream.fromIterable(lateEvents)), + ), + ), + }, + }; + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + startAcpRuntime, + stopDrainTimeout: "1 second", + afterStopTurnTerminal: () => + Deferred.succeed(releaseLateEvents, undefined).pipe( + Effect.andThen( + Deferred.await(lateEventsHandled).pipe( + Effect.timeoutOption("1 second"), + Effect.asVoid, + ), + ), + ), + }); + const promptStarted = yield* Deferred.make(); + const sessionExited = yield* Deferred.make(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "content.delta" && event.payload.delta === "prompt reached mock" + ? Deferred.succeed(promptStarted, undefined).pipe(Effect.ignore) + : event.type === "session.exited" + ? Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kilo"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const send = yield* adapter + .sendTurn({ threadId, input: "hang before late timeout output", attachments: [] }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(promptStarted); + const stopping = yield* adapter.stopSession(threadId).pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(drainStarted); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(releaseLateEvents); + yield* TestClock.adjust("1 second"); + const stopExit = yield* Fiber.join(stopping); + yield* Deferred.await(drainFinalized); + yield* Deferred.await(sessionExited); + yield* Fiber.join(send); + + assert.isTrue(Exit.isSuccess(stopExit)); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed"); + assert.isAtLeast(completedIndex, 0); + const completed = runtimeEvents[completedIndex]; + if (completed?.type !== "turn.completed") { + return assert.fail("expected a turn.completed runtime event"); + } + const outputTypes = new Set([ + "content.delta", + "item.started", + "item.updated", + "item.completed", + "turn.plan.updated", + ]); + assert.deepEqual( + runtimeEvents + .slice(completedIndex + 1) + .filter((event) => event.turnId === completed.turnId && outputTypes.has(event.type)), + [], + ); + assert.deepEqual( + runtimeEvents.filter( + (event) => + event.type === "content.delta" && event.payload.delta === "late after timed-out drain", + ), + [], + ); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); + assert.equal(runtimeEvents.filter((event) => event.type === "session.exited").length, 1); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + assert.isFalse(yield* Deferred.isDone(lateEventsHandled)); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "exit:"); + yield* Fiber.interrupt(eventsFiber); + }), + ); + it.effect("finishes owned stop cleanup when the stop caller is interrupted", () => Effect.gen(function* () { const threadId = ThreadId.make("kilo-stop-caller-interrupted"); diff --git a/apps/server/src/provider/Layers/KiloAdapter.ts b/apps/server/src/provider/Layers/KiloAdapter.ts index 16a4ab9b018e..ded87ba1ce72 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.ts @@ -219,6 +219,8 @@ export interface KiloAdapterLiveOptions { readonly afterPromptClaim?: (turnId: TurnId) => Effect.Effect; /** Optional synchronization hook after a session stop claim. Used by focused interruption tests. */ readonly afterSessionStopClaim?: (threadId: ThreadId) => Effect.Effect; + /** Optional synchronization hook after stop publishes a turn terminal event. Used by focused tests. */ + readonly afterStopTurnTerminal?: (turnId: TurnId) => Effect.Effect; /** Optional synchronization hook before startup events publish. Used by focused interruption tests. */ readonly beforeStartupEvents?: (threadId: ThreadId) => Effect.Effect; /** Overrides ACP runtime startup. Used by focused adapter-boundary tests. */ @@ -722,6 +724,18 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( // Stop owns cleanup once it enters this region. A broken event // consumer must not strand the child or session indefinitely. yield* ctx.acp.drainEvents.pipe(Effect.timeoutOption(stopDrainTimeout), Effect.exit); + + // Once the drain boundary is known, no later notification belongs to + // this turn. Quiesce the consumer before publishing the terminal + // event so even an update that started after a successful barrier, or + // one already being mapped after a failed drain, cannot overtake + // cancellation. + ctx.interruptedTurnIds.add(stoppedTurnId); + const notificationFiber = ctx.notificationFiber; + if (notificationFiber) { + ctx.notificationFiber = undefined; + yield* Fiber.interrupt(notificationFiber); + } yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -730,6 +744,7 @@ export const makeKiloAdapter = Effect.fn("makeKiloAdapter")(function* ( turnId: stoppedTurnId, payload: { state: "cancelled", stopReason: "cancelled" }, }); + yield* options?.afterStopTurnTerminal?.(stoppedTurnId) ?? Effect.void; } ctx.promptsInFlight = 0; ctx.activeTurnId = undefined;