diff --git a/AGENTS.md b/AGENTS.md index 38df1e94fa8c..540f0f1b4354 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 and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, Kiro, OpenCode, Antigravity) 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, OpenCode, and Antigravity 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, Kiro, OpenCode, and Antigravity 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 27b5dc491693..b6f4596a243c 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, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Kiro, OpenCode, and Google Antigravity. 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, OpenCode, and Antigravity. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Kiro, OpenCode, and Antigravity. 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` +> - Kiro: install [Kiro CLI](https://kiro.dev/cli) and run `kiro-cli login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..7d34efbf01a1 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,5 +1,5 @@ import { Image } from "expo-image"; -import { Path, Svg } from "react-native-svg"; +import { Path, Rect, Svg } from "react-native-svg"; import { View } from "react-native"; import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -53,6 +53,26 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "kiro") { + return ( + + + + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e4fd848ab6e1..b1bc0147706d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -15,6 +15,10 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1"; +// Kiro advertises no auth methods and answers `authenticate` with "Method not found". +const rejectAuthenticate = process.env.T3_ACP_REJECT_AUTHENTICATE === "1"; +// Kiro-shaped permission input: a bare command plus the model's per-call purpose note. +const kiroPermissionInput = process.env.T3_ACP_KIRO_PERMISSION_INPUT === "1"; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -419,15 +423,17 @@ const program = Effect.gen(function* () { // Mirrors the real agent: the API key method reads GEMINI_API_KEY from the // process environment and rejects when it is missing. yield* agent.handleAuthenticate((request) => - !antigravityProfile || request.methodId === "oauth-personal" - ? Effect.succeed({}) - : request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY + rejectAuthenticate + ? Effect.fail(AcpError.AcpRequestError.methodNotFound("authenticate")) + : !antigravityProfile || request.methodId === "oauth-personal" ? Effect.succeed({}) - : Effect.fail( - AcpError.AcpRequestError.invalidParams( - `Mock Antigravity rejected auth method ${request.methodId}.`, + : request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY + ? Effect.succeed({}) + : Effect.fail( + AcpError.AcpRequestError.invalidParams( + `Mock Antigravity rejected auth method ${request.methodId}.`, + ), ), - ), ); if (antigravityProfile) { yield* agent.handleLogout(() => Effect.succeed({})); @@ -1042,11 +1048,16 @@ const program = Effect.gen(function* () { title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, kind: "execute", status: "pending", - rawInput: { - variant: "Bash", - command, - description: index === 0 ? "Read package metadata" : "Read it again", - }, + rawInput: kiroPermissionInput + ? { + command, + __tool_use_purpose: index === 0 ? "Read package metadata" : "Read it again", + } + : { + variant: "Bash", + command, + description: index === 0 ? "Read package metadata" : "Read it again", + }, content: [ { type: "content", diff --git a/apps/server/src/provider/Drivers/KiroDriver.ts b/apps/server/src/provider/Drivers/KiroDriver.ts new file mode 100644 index 000000000000..7741a1867178 --- /dev/null +++ b/apps/server/src/provider/Drivers/KiroDriver.ts @@ -0,0 +1,136 @@ +import { KiroSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeKiroTextGeneration } from "../../textGeneration/KiroTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeKiroAdapter } from "../Layers/KiroAdapter.ts"; +import { + buildInitialKiroProviderSnapshot, + checkKiroProviderStatus, + enrichKiroSnapshot, +} from "../Layers/KiroProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeKiroSettings = Schema.decodeSync(KiroSettings); + +const DRIVER_KIND = ProviderDriverKind.make("kiro"); +// Kiro ships through its own installer, not npm, so updates stay manual. +const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type KiroDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +export const KiroDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Kiro", + supportsMultipleInstances: true, + }, + configSchema: KiroSettings, + defaultConfig: (): KiroSettings => decodeKiroSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies KiroSettings; + const adapter = yield* makeKiroAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeKiroTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkKiroProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialKiroProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichKiroSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities: MAINTENANCE_CAPABILITIES, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Kiro snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/KiroAdapter.test.ts b/apps/server/src/provider/Layers/KiroAdapter.test.ts new file mode 100644 index 000000000000..6a0d829a6154 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroAdapter.test.ts @@ -0,0 +1,302 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + ApprovalRequestId, + KiroSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { + kiroApprovalOperationInput, + makeKiroAdapter, + selectKiroPermissionOptionId, +} from "./KiroAdapter.ts"; + +const decodeKiroSettings = Schema.decodeSync(KiroSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +// Every mock rejects `authenticate` the way the real Kiro agent does, so a +// passing session start proves the runtime never sends it. +async function makeMockKiroWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kiro-acp-mock-")); + return writeFakeCli({ + directory: dir, + name: "fake-kiro-cli", + env: { T3_ACP_REJECT_AUTHENTICATE: "1", ...extraEnv }, + source: execScriptSource({ scriptPath: mockAgentPath, expectedArgs: ["acp"] }), + }); +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const kiroAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-kiro-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeKiroAdapter(decodeKiroSettings({ binaryPath }), options).pipe(Effect.orDie); + +it("drops Kiro's per-call purpose note from the approval identity", () => { + assert.deepEqual( + kiroApprovalOperationInput({ command: "echo hi", __tool_use_purpose: "Say hello" }), + { command: "echo hi" }, + ); + assert.deepEqual(kiroApprovalOperationInput({ path: "/tmp/x" }), { path: "/tmp/x" }); + assert.equal(kiroApprovalOperationInput("raw"), "raw"); +}); + +it("maps Always allow to allow_once when Kiro omits allow_always", () => { + const request = { + sessionId: "mock-session-1", + toolCall: { toolCallId: "tool-call-1", title: "Running: echo hi" }, + options: [ + { optionId: "allow_once", name: "Yes", kind: "allow_once" as const }, + { optionId: "reject_once", name: "No", kind: "reject_once" as const }, + ], + }; + assert.equal(selectKiroPermissionOptionId(request, "acceptForSession"), "allow_once"); + assert.equal(selectKiroPermissionOptionId(request, "acceptAlways"), "allow_once"); + assert.equal(selectKiroPermissionOptionId(request, "accept"), "allow_once"); + assert.equal(selectKiroPermissionOptionId(request, "decline"), "reject_once"); +}); + +it("maps acceptAlways to allow_always like acceptForSession when Kiro offers it", () => { + const request = { + sessionId: "mock-session-1", + toolCall: { toolCallId: "tool-call-1", title: "Running: echo hi" }, + options: [ + { optionId: "allow_once", name: "Yes", kind: "allow_once" as const }, + { optionId: "allow_always", name: "Always", kind: "allow_always" as const }, + { optionId: "reject_once", name: "No", kind: "reject_once" as const }, + ], + }; + assert.equal(selectKiroPermissionOptionId(request, "acceptAlways"), "allow_always"); + assert.equal(selectKiroPermissionOptionId(request, "acceptForSession"), "allow_always"); +}); + +it.layer(kiroAdapterTestLayer)("KiroAdapterLive", (it) => { + it.effect("starts without ACP authenticate and maps the mock prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-mock-thread"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kiro-requests-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiroWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kiro"), + cwd: process.cwd(), + runtimeMode: "full-access", + // The mock agent's non-default model id; Kiro forwards any id the catalog advertises. + modelSelection: { instanceId: ProviderInstanceId.make("kiro"), model: "grok-mock-alt" }, + }); + + assert.equal(session.provider, "kiro"); + assert.equal(session.model, "grok-mock-alt"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ threadId, input: "hello kiro", attachments: [] }); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const types = runtimeEvents.map((event) => event.type); + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "item.started", + "content.delta", + "turn.completed", + ] as const); + const delta = runtimeEvents.find((event) => event.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const methods = requests.map((request) => request.method); + assert.notInclude(methods, "authenticate"); + assert.include(methods, "session/set_model"); + const prompt = requests.find((request) => request.method === "session/prompt"); + assert.isDefined(prompt); + const promptParts = (prompt!.params as { prompt: Array<{ type: string; text: string }> }) + .prompt; + assert.deepEqual(promptParts[0], { type: "text", text: "hello kiro" }); + assert.include(promptParts[1]?.text, "Kiro harness, as grok-mock-alt"); + }), + ); + + it.effect("remembers Always allow across Kiro's changing tool purpose notes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-always-allow-purpose"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiroWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_KIRO_PERMISSION_INPUT: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + T3_ACP_PERMISSION_TITLE: "Running: cat server/package.json", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + yield* Ref.update(openedCount, (count) => count + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "acceptForSession", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kiro"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "approve this session", attachments: [] }); + + assert.equal(yield* Ref.get(openedCount), 1); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("asks before a different command after Always allow this session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-session-approval-scope"); + const wrapperPath = yield* Effect.promise(() => + makeMockKiroWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_KIRO_PERMISSION_INPUT: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + T3_ACP_PERMISSION_TITLE: "Running a shell command", + T3_ACP_SECOND_PERMISSION_COMMAND: "rm server/package.json", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + const count = yield* Ref.updateAndGet(openedCount, (value) => value + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + count === 1 ? "acceptForSession" : "decline", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kiro"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "check approval scope", attachments: [] }); + assert.equal(yield* Ref.get(openedCount), 2); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects rollback and structured user input without dropping the session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-unsupported-operations"); + const wrapperPath = yield* Effect.promise(() => makeMockKiroWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "Remember this turn" }); + const originalTurns = [...(yield* adapter.readThread(threadId)).turns]; + + assert.isFalse(adapter.capabilities.supportsConversationRollback); + const rollbackError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); + assert.equal(rollbackError._tag, "ProviderAdapterRequestError"); + const userInputError = yield* adapter + .respondToUserInput(threadId, ApprovalRequestId.make("missing"), {}) + .pipe(Effect.flip); + assert.equal(userInputError._tag, "ProviderAdapterRequestError"); + + assert.deepStrictEqual((yield* adapter.readThread(threadId)).turns, originalTurns); + assert.isTrue(yield* adapter.hasSession(threadId)); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockKiroWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const error = yield* adapter + .startSession({ + threadId: ThreadId.make("kiro-provider-mismatch"), + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterValidationError"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KiroAdapter.ts b/apps/server/src/provider/Layers/KiroAdapter.ts new file mode 100644 index 000000000000..6bdeb8e80e77 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroAdapter.ts @@ -0,0 +1,1914 @@ +import { + ApprovalRequestId, + type KiroSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +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 FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyKiroAcpModelSelection, + currentKiroModelIdFromSessionSetup, + makeKiroAcpRuntime, + resolveKiroAcpBaseModelId, +} from "../acp/KiroAcpSupport.ts"; +import { type KiroAdapterShape } from "../Services/KiroAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("kiro"); +const KIRO_RESUME_VERSION = 1 as const; +const NANOS_PER_MILLI = 1_000_000n; +// Kiro streams no ACP progress while a model thinks. Once it has emitted +// standard ACP progress, ten silent minutes is long enough to avoid treating +// legitimate reasoning as a stalled stream. +const DEFAULT_KIRO_TURN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000; +// A tool can legitimately run without emitting text for much longer than +// reasoning. It still needs a deadline so a lost tool update cannot leave the +// turn working forever. +const DEFAULT_KIRO_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000; +// Kiro attaches the model's stated purpose to every tool input. It changes +// between otherwise identical calls, so it must not be part of an approval key. +const KIRO_TOOL_PURPOSE_KEY = "__tool_use_purpose"; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface KiroAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; + /** Override the conservative ACP turn liveness timeout in focused tests. */ + readonly turnInactivityTimeoutMs?: number; + /** Override the longer active-tool liveness timeout in focused tests. */ + readonly activeToolInactivityTimeoutMs?: number; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface KiroTurnLivenessSignal { + readonly turnId: TurnId; +} + +interface KiroSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Prompt fibers still expected to settle for an interrupted turn. The + * turn id is dropped from interruptedTurnIds once this reaches zero, so a + * long-lived session does not accumulate interrupted turn ids forever. */ + interruptedTurnPendingSettlements: Map; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * cancels the in-flight prompt and continues the same turn. Only the last + * remaining prompt settles the turn. */ + promptsInFlight: number; + /** Monotonic id assigned to each sendTurn. Steers discard older epochs. */ + promptEpoch: number; + /** Prompt epochs below this value must not start an ACP session/prompt. */ + discardBeforeEpoch: number; + /** Serializes cancel-then-prompt so a steer cannot miss or hit the wrong RPC. */ + readonly promptLifecycle: Semaphore.Semaphore; + readonly livenessSignals: Queue.Queue; + livenessTurnId: TurnId | undefined; + lastTurnActivityAtNanos: bigint | undefined; + readonly activeToolCallIds: Set; + livenessUpdatesInFlight: number; + /** Prompt RPCs that returned before their turn settlement acquired the lock. */ + promptResponsesReady: number; + currentModelId: string | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: KiroSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: KiroSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: KiroSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +function parseKiroResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== KIRO_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +export function selectKiroPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const preferredKind = + decision === "acceptForSession" || decision === "acceptAlways" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const preferred = request.options.find((entry) => entry.kind === preferredKind); + const preferredId = preferred?.optionId.trim(); + if (preferredId) { + return preferredId; + } + // Some Kiro tools omit allow_always. T3 still offers "Always allow this session". + if (decision === "acceptForSession" || decision === "acceptAlways") { + const once = request.options.find((entry) => entry.kind === "allow_once"); + const onceId = once?.optionId.trim(); + if (onceId) { + return onceId; + } + } + return undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectKiroPermissionOptionId(request, "acceptForSession") ?? + selectKiroPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + return response?.stopReason ?? null; +} + +/** + * Identity of a tool operation for "Always allow this session". Kiro's + * per-call purpose annotation is dropped so the same command matches again. + */ +export function kiroApprovalOperationInput(rawInput: unknown): unknown { + if (!isRecord(rawInput)) { + return rawInput; + } + const { [KIRO_TOOL_PURPOSE_KEY]: _purpose, ...operationInput } = rawInput; + return operationInput; +} + +function kiroPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeKiroAdapter(kiroSettings: KiroSettings, options?: KiroAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("kiro"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + const requestedTurnInactivityTimeoutMs = options?.turnInactivityTimeoutMs; + const turnInactivityTimeoutMs = + typeof requestedTurnInactivityTimeoutMs === "number" && + Number.isFinite(requestedTurnInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedTurnInactivityTimeoutMs)) + : DEFAULT_KIRO_TURN_INACTIVITY_TIMEOUT_MS; + const turnInactivityTimeoutNanos = BigInt(turnInactivityTimeoutMs) * NANOS_PER_MILLI; + const requestedActiveToolInactivityTimeoutMs = options?.activeToolInactivityTimeoutMs; + const activeToolInactivityTimeoutMs = + typeof requestedActiveToolInactivityTimeoutMs === "number" && + Number.isFinite(requestedActiveToolInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedActiveToolInactivityTimeoutMs)) + : DEFAULT_KIRO_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS; + const activeToolInactivityTimeoutNanos = + BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI; + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Kiro runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Kiro ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const signalTurnLiveness = (ctx: KiroSessionContext, turnId: TurnId) => + Queue.offer(ctx.livenessSignals, { turnId }).pipe(Effect.asVoid); + + const beginTurnLiveness = (ctx: KiroSessionContext, turnId: TurnId) => + Effect.sync(() => { + ctx.livenessTurnId = turnId; + // Do not start a deadline until ACP has made observable progress. + // Kiro emits nothing while its model reasons. + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + }); + + const clearTurnLiveness = (ctx: KiroSessionContext) => { + const turnId = ctx.livenessTurnId; + ctx.livenessTurnId = undefined; + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + ctx.livenessUpdatesInFlight = 0; + ctx.promptResponsesReady = 0; + return turnId === undefined ? Effect.void : signalTurnLiveness(ctx, turnId); + }; + + const recordTurnActivity = Effect.fn("KiroAdapter.recordTurnActivity")(function* ( + ctx: KiroSessionContext, + turnId: TurnId, + event: Extract< + AcpSessionRuntime.AcpSessionRuntimeEvent, + { + _tag: + | "AssistantItemStarted" + | "AssistantItemCompleted" + | "PlanUpdated" + | "ToolCallUpdated" + | "ContentDelta"; + } + >, + ) { + if ( + ctx.livenessTurnId !== turnId || + (event._tag === "ContentDelta" && event.text.length === 0) + ) { + return; + } + ctx.livenessUpdatesInFlight += 1; + try { + const activityAtNanos = yield* Clock.monotonicTimeNanos; + if (ctx.livenessTurnId !== turnId || ctx.interruptedTurnIds.has(turnId)) { + return; + } + if (event._tag === "ToolCallUpdated") { + if (event.toolCall.status === "completed" || event.toolCall.status === "failed") { + ctx.activeToolCallIds.delete(event.toolCall.toolCallId); + } else { + // A tool update without a terminal status receives a longer + // deadline so a long-running tool is not mistaken for a stall. + ctx.activeToolCallIds.add(event.toolCall.toolCallId); + } + } + ctx.lastTurnActivityAtNanos = activityAtNanos; + } finally { + // Decrement before signaling. The watchdog treats in-flight updates as a + // pause; if it consumed a signal while the counter was still > 0 it would + // wait on the next take with no follow-up wake after this decrement. + ctx.livenessUpdatesInFlight = Math.max(0, ctx.livenessUpdatesInFlight - 1); + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const hasLivenessPause = (ctx: KiroSessionContext) => + ctx.pendingApprovals.size > 0 || ctx.livenessUpdatesInFlight > 0; + + const livenessTimeoutFor = (ctx: KiroSessionContext) => + ctx.activeToolCallIds.size > 0 + ? { + milliseconds: activeToolInactivityTimeoutMs, + nanos: activeToolInactivityTimeoutNanos, + } + : { milliseconds: turnInactivityTimeoutMs, nanos: turnInactivityTimeoutNanos }; + + const signalSessionTurnLiveness = (threadId: ThreadId, turnId: TurnId | undefined) => { + const ctx = sessions.get(threadId); + return ctx && turnId !== undefined ? signalTurnLiveness(ctx, turnId) : Effect.void; + }; + + const resumeSessionTurnLiveness = Effect.fn("KiroAdapter.resumeSessionTurnLiveness")(function* ( + threadId: ThreadId, + turnId: TurnId | undefined, + ) { + const ctx = sessions.get(threadId); + if (!ctx || turnId === undefined || ctx.livenessTurnId !== turnId) { + return; + } + // An approval or user-input wait can last longer than the watchdog. + // Its resolution gives the provider a fresh window to resume output. + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }); + + const refreshSessionTurnLiveness = Effect.fn("KiroAdapter.refreshSessionTurnLiveness")( + function* (threadId: ThreadId, turnId: TurnId | undefined) { + const ctx = sessions.get(threadId); + if ( + !ctx || + turnId === undefined || + ctx.livenessTurnId !== turnId || + ctx.lastTurnActivityAtNanos === undefined + ) { + return; + } + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }, + ); + + const markPromptResponseReady = Effect.fn("KiroAdapter.markPromptResponseReady")(function* ( + threadId: ThreadId, + acpSessionId: string, + turnId: TurnId, + ) { + const ctx = sessions.get(threadId); + if ( + ctx && + ctx.acpSessionId === acpSessionId && + !ctx.stopped && + !ctx.interruptedTurnIds.has(turnId) && + ctx.livenessTurnId === turnId && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId + ) { + ctx.promptResponsesReady += 1; + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const consumePromptResponseReady = (ctx: KiroSessionContext) => { + ctx.promptResponsesReady = Math.max(0, ctx.promptResponsesReady - 1); + }; + + // Snapshots the outstanding prompt fibers for `turnId` so their later, + // individually-arriving settlements can be counted down to zero before + // the id is forgotten. Idempotent: a turn already marked keeps its + // original snapshot rather than restarting the count. + const markTurnInterrupted = (ctx: KiroSessionContext, turnId: TurnId) => { + if (ctx.interruptedTurnIds.has(turnId)) { + return; + } + ctx.interruptedTurnIds.add(turnId); + ctx.interruptedTurnPendingSettlements.set(turnId, Math.max(ctx.promptsInFlight, 1)); + }; + + // Call once per prompt fiber that finds its turn already interrupted and + // returns without progressing it further. Once every such fiber has + // reported in, the turn id is safe to drop. + const settleInterruptedTurnPrompt = (ctx: KiroSessionContext, turnId: TurnId) => { + const remaining = ctx.interruptedTurnPendingSettlements.get(turnId); + if (remaining === undefined) { + return; + } + if (remaining <= 1) { + ctx.interruptedTurnPendingSettlements.delete(turnId); + ctx.interruptedTurnIds.delete(turnId); + } else { + ctx.interruptedTurnPendingSettlements.set(turnId, remaining - 1); + } + }; + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = kiroPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if (liveCtx.acpSessionId !== expectedAcpSessionId) { + return; + } + if (liveCtx.interruptedTurnIds.has(turnId)) { + settleInterruptedTurnPrompt(liveCtx, turnId); + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + yield* clearTurnLiveness(liveCtx); + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + yield* clearTurnLiveness(liveCtx); + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const isLiveTurn = (ctx: KiroSessionContext, turnId: TurnId) => + ctx.promptsInFlight > 0 && + ctx.promptsInFlight > ctx.promptResponsesReady && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId && + (ctx.session.status === "running" || ctx.session.status === "connecting"); + + const settleStalledTurn = Effect.fn("KiroAdapter.settleStalledTurn")(function* ( + ctx: KiroSessionContext, + turnId: TurnId, + ) { + return yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if ( + liveCtx !== ctx || + ctx.stopped || + !isLiveTurn(ctx, turnId) || + ctx.interruptedTurnIds.has(turnId) || + hasLivenessPause(ctx) + ) { + return; + } + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + return; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + if ( + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) || + nowNanos - lastActivityAtNanos < livenessTimeoutFor(ctx).nanos + ) { + return; + } + + // Mark before cancel/drain so notifications already in flight finish + // before the terminal event, while late notifications are dropped. + markTurnInterrupted(ctx, turnId); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error), + ), + ), + ); + yield* Effect.ignore(ctx.acp.drainEvents); + yield* settlePromptInFlight(ctx.threadId, turnId, ctx.acpSessionId, { + errorMessage: `Kiro ACP turn stalled without content or tool progress for ${livenessTimeoutFor(ctx).milliseconds}ms.`, + settleAllPrompts: true, + }); + }), + ); + }); + + const runTurnLivenessWatchdog = Effect.fn("KiroAdapter.runTurnLivenessWatchdog")( + function* (ctx: KiroSessionContext) { + while (true) { + if (ctx.stopped) { + return; + } + const turnId = ctx.livenessTurnId; + if ( + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) + ) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); + if (remainingNanos <= 0n) { + yield* settleStalledTurn(ctx, turnId); + continue; + } + + const wakeReason = yield* Effect.raceFirst( + Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), + Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), + ); + if (wakeReason === "timeout") { + yield* settleStalledTurn(ctx, turnId); + } + } + }, + Effect.catch(() => Effect.void), + ); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Kiro notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: KiroSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: KiroSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + 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" }, + }); + }); + + const startSession: KiroAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && 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 kiroModelSelection = + 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 sessionApprovedOperations = new Set(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseKiroResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeKiroAcpRuntime({ + kiroSettings, + ...(options?.environment || mcpSession?.agentDeviceEnvironment + ? { + environment: McpProviderSession.withAgentDeviceEnvironment( + options?.environment ?? process.env, + mcpSession, + ), + } + : {}), + childProcessSpawner, + cwd, + runtimeMode: input.runtimeMode, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + const permissionRequest = parsePermissionRequest(params); + const command = permissionRequest.toolCall?.command; + const { kind, title, rawInput, locations } = params.toolCall; + const operationInput = kiroApprovalOperationInput(rawInput); + // Remember the operation, not the tool-call id or every future tool. + // Generic titles without input cannot identify an operation safely. + const approvalKey = + command || (isRecord(rawInput) && Object.keys(rawInput).length > 0) + ? stableStringify({ kind, title, command, input: operationInput, locations }) + : undefined; + const alreadyApproved = + approvalKey !== undefined && sessionApprovedOperations.has(approvalKey); + if (input.runtimeMode === "full-access" || alreadyApproved) { + const autoApprovedOptionId = + input.runtimeMode === "full-access" + ? selectAutoApprovedPermissionOption(params) + : selectKiroPermissionOptionId(params, "accept"); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* signalSessionTurnLiveness(input.threadId, turnId); + 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, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" + ? undefined + : selectKiroPermissionOptionId(params, resolved); + if ( + (resolved === "acceptForSession" || resolved === "acceptAlways") && + selectedOptionId && + approvalKey !== undefined + ) { + sessionApprovedOperations.add(approvalKey); + } + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = kiroModelSelection?.model + ? resolveKiroAcpBaseModelId(kiroModelSelection.model) + : undefined; + const currentStartModelId = currentKiroModelIdFromSessionSetup( + started.sessionSetupResult, + ); + const boundModelId = yield* applyKiroAcpModelSelection({ + runtime: acp, + currentModelId: currentStartModelId, + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveKiroAcpBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: KIRO_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: KiroSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + interruptedTurnPendingSettlements: new Map(), + promptsInFlight: 0, + promptEpoch: 0, + discardBeforeEpoch: 0, + promptLifecycle: yield* Semaphore.make(1), + livenessSignals: yield* Queue.sliding(1), + livenessTurnId: undefined, + lastTurnActivityAtNanos: undefined, + activeToolCallIds: new Set(), + livenessUpdatesInFlight: 0, + promptResponsesReady: 0, + currentModelId: boundModelId, + stopped: false, + }; + + 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; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + if ( + event._tag === "AssistantItemStarted" || + event._tag === "AssistantItemCompleted" || + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* recordTurnActivity(ctx, notificationTurnId, event); + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + 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 Kiro runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid); + 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: "Kiro ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: KiroAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // interruptTurn/settleStalledTurn mark their target before they + // acquire this lock, so a queued sendTurn can arrive here first + // and still see the doomed turn as "active". Steering it would + // hand this prompt the dying turn's id and let its own + // settlement race the real interrupt for the same bookkeeping + // slot. Settle the old turn ourselves so this prompt always + // starts a clean, uncontested turn instead. + if ( + ctx.promptsInFlight > 0 && + ctx.activeTurnId !== undefined && + ctx.interruptedTurnIds.has(ctx.activeTurnId) + ) { + const staleTurnId = ctx.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/cancel", error), + ), + ), + ); + yield* settlePromptInFlight(input.threadId, staleTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } + // A sendTurn while a prompt is in flight is a steer: reuse the + // active turn and cancel the in-flight ACP prompt so Kiro takes + // the new instruction immediately, matching Claude/Codex, instead + // of waiting behind serialized session/prompt. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + ctx.promptEpoch += 1; + const promptEpoch = ctx.promptEpoch; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + 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 requestedTurnModelId = turnModelSelection?.model + ? resolveKiroAcpBaseModelId(turnModelSelection.model) + : undefined; + + const text = input.input?.trim(); + // Kiro's prompt capabilities accept images only. Generic files + // reach the agent through the path line ProviderService puts in + // the prompt. + const imagePromptParts = yield* Effect.forEach( + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), + (attachment) => + Effect.gen(function* () { + 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: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const currentModelId = yield* applyKiroAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveKiroAcpBaseModelId(currentModelId) + : undefined; + const runtimeInstructions = buildRuntimeInstructions({ + harness: "Kiro", + model: displayModel, + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kiro prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + if (steeringTurnId === undefined) { + yield* beginTurnLiveness(ctx, turnId); + } else { + yield* refreshSessionTurnLiveness(input.threadId, turnId); + } + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } else { + // Discard the previous epoch only after this replacement is + // ready. A failed steer must not skip the live prompt, which + // settles without a terminal event when emitTurnCompletion is + // false. + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + ctx.discardBeforeEpoch = promptEpoch; + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + runtimeInstructions, + turnId, + promptEpoch, + promptLifecycle: ctx.promptLifecycle, + steeringTurnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Kiro prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const promptStart = yield* prepared.promptLifecycle.withPermit( + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + const interrupted = liveCtx?.interruptedTurnIds.has(prepared.turnId) === true; + if ( + !liveCtx || + liveCtx.acpSessionId !== prepared.acpSessionId || + prepared.promptEpoch < liveCtx.discardBeforeEpoch || + interrupted + ) { + return { _tag: "Skipped" as const, interrupted }; + } + if (prepared.steeringTurnId !== undefined) { + yield* Effect.ignore( + liveCtx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/cancel", error), + ), + ), + ); + } + if (liveCtx.interruptedTurnIds.has(prepared.turnId)) { + return { _tag: "Skipped" as const, interrupted: true }; + } + const dispatched = yield* Deferred.make(); + const fiber = yield* liveCtx.acp + .prompt( + { + prompt: [ + ...prepared.promptParts, + { type: "text", text: prepared.runtimeInstructions }, + ], + }, + { dispatched }, + ) + .pipe(Effect.forkChild({ startImmediately: true })); + // Hold the lifecycle permit until the runtime has registered this + // prompt's RPC fiber, so a later steer's session/cancel targets + // this prompt. Fall through if the prompt fails before that point. + yield* Effect.raceFirst( + Deferred.await(dispatched), + Fiber.await(fiber).pipe(Effect.asVoid), + ); + return { _tag: "Started" as const, fiber }; + }), + ); + if (promptStart._tag === "Skipped") { + // Settle after releasing promptLifecycle. Holding both locks + // deadlocks the next sendTurn, which takes the thread lock first. + yield* withThreadLock( + input.threadId, + settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + promptStart.interrupted + ? { + completedStopReason: "cancelled", + settleAllPrompts: true, + } + : { emitTurnCompletion: false }, + ), + ); + yield* Ref.set(promptSettled, true); + const liveCtx = sessions.get(input.threadId); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: liveCtx?.session.resumeCursor, + }; + } + + const result = yield* Fiber.join(promptStart.fiber).pipe( + Effect.tap((promptResult) => + Effect.all( + [ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), + ], + { discard: true }, + ), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Kiro session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kiro session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + consumePromptResponseReady(ctx); + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + settleInterruptedTurnPrompt(ctx, prepared.turnId); + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + settleInterruptedTurnPrompt(ctx, prepared.turnId); + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + yield* clearTurnLiveness(ctx); + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + 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; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Kiro session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + settleInterruptedTurnPrompt(ctx, prepared.turnId); + return; + } + consumePromptResponseReady(ctx); + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Kiro prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: KiroAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + markTurnInterrupted(ctx, interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + markTurnInterrupted(ctx, interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + yield* clearTurnLiveness(ctx); + } + }), + ); + }); + + const respondToRequest: KiroAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + // Kiro asks its questions as assistant text, so there is never a pending + // structured request to answer. + const respondToUserInput: KiroAdapterShape["respondToUserInput"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/user_input", + detail: `Unknown pending user-input request: ${requestId}`, + }); + }); + + const readThread: KiroAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: KiroAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Kiro ACP sessions do not support provider-side rollback.", + }); + }); + + const stopSession: KiroAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: KiroAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: KiroAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: KiroAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + compaction: { type: "slash-command", command: "/compact" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies KiroAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/KiroProvider.test.ts b/apps/server/src/provider/Layers/KiroProvider.test.ts new file mode 100644 index 000000000000..3343bb5c8fb2 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroProvider.test.ts @@ -0,0 +1,250 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { KiroSettings } from "@t3tools/contracts"; + +import { + buildInitialKiroProviderSnapshot, + checkKiroProviderStatus, + parseKiroModelsCliOutput, + parseKiroWhoamiOutput, +} from "./KiroProvider.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; + +const decodeKiroSettings = Schema.decodeSync(KiroSettings); + +const LOGGED_IN_WHOAMI_OUTPUT = [ + '{"accountType":"IamIdentityCenter","email":"dev@example.com","region":"us-east-1","startUrl":"https://example.awsapps.com/start"}', + "", + "Profile:", + "TestProfile", + "arn:aws:codewhisperer:us-east-1:123456789012:profile/ABCDEFGHIJKL", + "", +].join("\n"); + +const LOGGED_OUT_WHOAMI_OUTPUT = "Not logged in. Run `kiro-cli login` to sign in.\n"; + +const LOGGED_IN_WHOAMI_NULL_EMAIL_OUTPUT = [ + '{"accountType":"IamIdentityCenter","email":null,"region":"us-east-1"}', + "", + "Profile:", + "TestProfile", + "", +].join("\n"); + +const LIST_MODELS_OUTPUT = JSON.stringify({ + models: [ + { + model_name: "auto", + description: "Models chosen by task", + model_id: "auto", + context_window_tokens: 1000000, + rate_multiplier: 1.0, + rate_unit: "Credit", + }, + { + model_name: "claude-sonnet-5", + description: "Claude Sonnet 5 model with 1M context window", + model_id: "claude-sonnet-5", + context_window_tokens: 1000000, + rate_multiplier: 1.3, + rate_unit: "Credit", + }, + { model_name: "claude-sonnet-5", model_id: " claude-sonnet-5 " }, + ], + default_model: "auto", +}); + +describe("parseKiroWhoamiOutput", () => { + it("reads the JSON line ahead of the plain-text profile trailer", () => { + expect(parseKiroWhoamiOutput(LOGGED_IN_WHOAMI_OUTPUT)).toEqual({ + authenticated: true, + email: "dev@example.com", + accountType: "IamIdentityCenter", + }); + }); + + it("recognizes a signed-out CLI", () => { + expect(parseKiroWhoamiOutput(LOGGED_OUT_WHOAMI_OUTPUT).authenticated).toBe(false); + }); + + it("stays authenticated when Kiro CLI 1.28.2 reports a null email", () => { + expect(parseKiroWhoamiOutput(LOGGED_IN_WHOAMI_NULL_EMAIL_OUTPUT)).toEqual({ + authenticated: true, + accountType: "IamIdentityCenter", + }); + }); + + it("returns unknown auth for unrecognized output", () => { + expect(parseKiroWhoamiOutput("kiro-cli 2.21.3\n").authenticated).toBeNull(); + }); +}); + +describe("parseKiroModelsCliOutput", () => { + it("maps the catalog, marks the default, and drops duplicate ids", () => { + const models = parseKiroModelsCliOutput(LIST_MODELS_OUTPUT); + expect(models.map((model) => [model.slug, model.name, model.isDefault ?? false])).toEqual([ + ["auto", "Auto", true], + ["claude-sonnet-5", "claude-sonnet-5", false], + ]); + }); + + it("returns no models for non-JSON output", () => { + expect(parseKiroModelsCliOutput("Error: not logged in\n")).toEqual([]); + }); +}); + +describe("buildInitialKiroProviderSnapshot", () => { + it.effect("returns a disabled snapshot by default because Kiro is opt-in", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKiroProviderSnapshot(decodeKiroSettings({})); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["auto"]); + }), + ); + + it.effect("returns a pending snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKiroProviderSnapshot( + decodeKiroSettings({ enabled: true }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Checking Kiro"); + expect(snapshot.supportsConversationRollback).toBe(false); + }), + ); +}); + +it.layer(NodeServices.layer)("checkKiroProviderStatus", (it) => { + // A stand-in for the Kiro CLI: `--version`, `whoami`, and `chat --list-models` + // print canned text. No probe may reach `acp`. + const writeFakeKiroCli = (input: { + readonly whoamiOutput: string; + readonly whoamiExitCode?: number; + readonly modelsOutput?: string; + }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kiro-probe-" }); + return writeFakeCli({ + directory: dir, + name: "kiro-cli", + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("kiro-cli 2.21.3\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] === "whoami") {', + // @effect-diagnostics-next-line preferSchemaOverJson:off + ` process.stdout.write(${JSON.stringify(input.whoamiOutput)});`, + ` process.exit(${input.whoamiExitCode ?? 0});`, + "}", + 'if (process.argv[2] === "chat" && process.argv.includes("--list-models")) {', + ...(input.modelsOutput === undefined + ? [" process.exit(1);"] + : [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + ` process.stdout.write(${JSON.stringify(`${input.modelsOutput}\n`)});`, + " process.exit(0);", + ]), + "}", + "process.stderr.write(`unexpected args: ${process.argv.slice(2).join(' ')}\\n`);", + "process.exit(7);", + "", + ].join("\n"), + }); + }); + + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkKiroProviderStatus( + decodeKiroSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/kiro-cli-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports ready with the account's models when logged in", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const kiroPath = yield* writeFakeKiroCli({ + whoamiOutput: LOGGED_IN_WHOAMI_OUTPUT, + modelsOutput: LIST_MODELS_OUTPUT, + }); + return yield* checkKiroProviderStatus( + decodeKiroSettings({ enabled: true, binaryPath: kiroPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("2.21.3"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Kiro account", + email: "dev@example.com", + }); + expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["auto", true], + ["claude-sonnet-5", false], + ]); + expect(snapshot.slashCommands.map((command) => command.name)).toContain("compact"); + }), + ); + + it.effect("reports unauthenticated from a signed-out `whoami` without listing models", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const kiroPath = yield* writeFakeKiroCli({ + whoamiOutput: LOGGED_OUT_WHOAMI_OUTPUT, + whoamiExitCode: 1, + }); + return yield* checkKiroProviderStatus( + decodeKiroSettings({ enabled: true, binaryPath: kiroPath }), + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("kiro-cli login"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["auto"]); + }), + ); + + it.effect("keeps the built-in model with a warning when the catalog cannot be read", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const kiroPath = yield* writeFakeKiroCli({ whoamiOutput: LOGGED_IN_WHOAMI_OUTPUT }); + return yield* checkKiroProviderStatus( + decodeKiroSettings({ enabled: true, binaryPath: kiroPath, customModels: ["glm-5"] }), + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => [model.slug, model.isCustom])).toEqual([ + ["auto", false], + ["glm-5", true], + ]); + expect(snapshot.message).toContain("model list could not be read"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KiroProvider.ts b/apps/server/src/provider/Layers/KiroProvider.ts new file mode 100644 index 000000000000..bddef7a1eace --- /dev/null +++ b/apps/server/src/provider/Layers/KiroProvider.ts @@ -0,0 +1,444 @@ +import { + type CustomModelSetting, + KIRO_DEFAULT_MODEL, + type KiroSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderAuth, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + AUTH_PROBE_TIMEOUT_MS, + buildServerProvider, + COMPACT_SLASH_COMMAND, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { resolveKiroAcpBaseModelId } from "../acp/KiroAcpSupport.ts"; + +const KIRO_PRESENTATION = { + displayName: "Kiro", + supportsConversationRollback: false, + badgeLabel: "Early Access", + showInteractionModeToggle: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; + +const KIRO_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: KIRO_DEFAULT_MODEL, + name: "Auto", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialKiroProviderSnapshot( + kiroSettings: KiroSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = kiroModelsFromSettings(kiroSettings.customModels); + + if (!kiroSettings.enabled) { + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kiro is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Kiro CLI availability...", + }, + }); + }); +} + +function kiroModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = KIRO_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +/** First JSON object line on stdout. Kiro prints human-readable trailers after its JSON payload. */ +function leadingJsonLine(output: string): string | undefined { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith("{")); +} + +const KiroWhoamiJson = Schema.Struct({ + accountType: Schema.optional(Schema.String), + email: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeKiroWhoamiJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(KiroWhoamiJson)); +const decodeKiroWhoami = (output: string) => { + const line = leadingJsonLine(output); + if (line === undefined) { + return undefined; + } + const exit = decodeKiroWhoamiJsonExit(line); + return Exit.isSuccess(exit) ? exit.value : undefined; +}; + +export interface KiroWhoamiCliOutput { + /** True or false when the CLI reported a login state, null when the output is unrecognized. */ + readonly authenticated: boolean | null; + readonly email?: string; + readonly accountType?: string; +} + +/** + * Parses `kiro-cli whoami --format json`. A signed-in CLI prints one JSON line + * (`{"accountType":"IamIdentityCenter","email":"...",...}`) followed by a + * plain-text profile block; a signed-out CLI exits non-zero with a + * "not logged in" message. + */ +export function parseKiroWhoamiOutput(output: string): KiroWhoamiCliOutput { + const decoded = decodeKiroWhoami(output); + if (decoded) { + const email = decoded.email?.trim(); + const accountType = decoded.accountType?.trim(); + return { + authenticated: true, + ...(email ? { email } : {}), + ...(accountType ? { accountType } : {}), + }; + } + return { + authenticated: /not logged in|not authenticated|please log in/i.test(output) ? false : null, + }; +} + +const KiroModelsJson = Schema.Struct({ + models: Schema.Array( + Schema.Struct({ + model_id: Schema.String, + model_name: Schema.optional(Schema.String), + }), + ), + default_model: Schema.optional(Schema.String), +}); +const decodeKiroModelsJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(KiroModelsJson)); +const decodeKiroModels = (output: string) => { + const line = leadingJsonLine(output); + if (line === undefined) { + return undefined; + } + const exit = decodeKiroModelsJsonExit(line); + return Exit.isSuccess(exit) ? exit.value : undefined; +}; + +/** + * Parses `kiro-cli chat --list-models --format json`. The command lists the + * account's catalog without starting an agent session, so the provider probe + * can refresh models without booting MCP servers. + */ +export function parseKiroModelsCliOutput(output: string): ReadonlyArray { + const decoded = decodeKiroModels(output); + if (!decoded) { + return []; + } + const defaultModel = decoded.default_model?.trim() || KIRO_DEFAULT_MODEL; + const seen = new Set(); + const models: ServerProviderModel[] = []; + for (const entry of decoded.models) { + const slug = resolveKiroAcpBaseModelId(entry.model_id); + if (!entry.model_id.trim() || seen.has(slug)) { + continue; + } + seen.add(slug); + models.push({ + slug, + name: displayNameFromKiroModel(entry.model_name?.trim() || slug), + isCustom: false, + ...(slug === defaultModel ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, + }); + } + return models; +} + +function displayNameFromKiroModel(name: string): string { + return name === KIRO_DEFAULT_MODEL ? "Auto" : name; +} + +const runKiroCliCommand = ( + kiroSettings: KiroSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = kiroSettings.binaryPath || "kiro-cli"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(function* ( + kiroSettings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = kiroModelsFromSettings(kiroSettings.customModels); + + if (!kiroSettings.enabled) { + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kiro is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runKiroCliCommand(kiroSettings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Kiro CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Kiro CLI (`kiro-cli`) is not installed or not on PATH." + : "Failed to execute Kiro CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but timed out while running `kiro-cli --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Kiro CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but failed to run.", + }, + }); + } + + // `whoami` reads the credential store without starting the agent. + const whoamiResult = yield* runKiroCliCommand( + kiroSettings, + ["whoami", "--format", "json"], + environment, + ).pipe(Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.result); + const whoami: KiroWhoamiCliOutput = + Result.isSuccess(whoamiResult) && Option.isSome(whoamiResult.success) + ? whoamiResult.success.value.code === 0 + ? parseKiroWhoamiOutput(whoamiResult.success.value.stdout) + : // A signed-out CLI exits non-zero; only trust that verdict when the text confirms it. + { + authenticated: + parseKiroWhoamiOutput( + `${whoamiResult.success.value.stdout}\n${whoamiResult.success.value.stderr}`, + ).authenticated === false + ? false + : null, + } + : { authenticated: null }; + if (whoami.authenticated === null) { + yield* Effect.logWarning("Kiro CLI login probe failed, timed out, or was unrecognized.", { + errorTag: Result.isFailure(whoamiResult) + ? whoamiResult.failure._tag + : Option.isNone(whoamiResult.success) + ? "Timeout" + : `ExitCode${whoamiResult.success.value.code}`, + }); + } + + const auth: ServerProviderAuth = + whoami.authenticated === true + ? { + status: "authenticated", + type: "cached_token", + label: "Kiro account", + ...(whoami.email ? { email: whoami.email } : {}), + } + : whoami.authenticated === false + ? { status: "unauthenticated" } + : { status: "unknown" }; + + if (auth.status === "unauthenticated") { + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth, + message: "Kiro CLI is installed but not logged in. Run `kiro-cli login`.", + }, + }); + } + + // The model catalog comes from the account, so it needs a login but no agent session. + const modelsExit = yield* runKiroCliCommand( + kiroSettings, + ["chat", "--list-models", "--format", "json"], + environment, + ).pipe(Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.exit); + const modelsOutput = + Exit.isSuccess(modelsExit) && + Option.isSome(modelsExit.value) && + modelsExit.value.value.code === 0 + ? modelsExit.value.value + : undefined; + const discoveredModels = modelsOutput ? parseKiroModelsCliOutput(modelsOutput.stdout) : []; + const modelsFailed = discoveredModels.length === 0; + if (modelsFailed) { + yield* Effect.logWarning("Kiro CLI model listing failed, timed out, or returned no models.", { + errorTag: Exit.isFailure(modelsExit) + ? causeErrorTag(modelsExit.cause) + : Option.isNone(modelsExit.value) + ? "Timeout" + : `ExitCode${modelsExit.value.value.code}`, + }); + } + const models = modelsFailed + ? fallbackModels + : kiroModelsFromSettings(kiroSettings.customModels, discoveredModels); + + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models, + slashCommands: [COMPACT_SLASH_COMMAND], + probe: { + installed: true, + version, + // A failed catalog probe degrades the model picker, it does not make chats fail. + status: modelsFailed ? "warning" : "ready", + auth, + ...(modelsFailed + ? { + message: + "Kiro CLI is installed but its model list could not be read. Model options may be incomplete.", + } + : {}), + }, + }); +}); + +export const enrichKiroSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Kiro version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 25dafa5ba040..c0a6543555e6 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,17 +10,18 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single - * `ProviderInstanceConfigMap` and asserts the registry boots them all - * without cross-contamination. This proves the driver SPI is uniform - * across every provider — any driver plugs into the registry through - * the same `ProviderDriver` value contract. + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `kiro`) in a + * single `ProviderInstanceConfigMap` and asserts the registry boots them + * all without cross-contamination. This proves the driver SPI is + * uniform across every provider — any driver plugs into the registry + * through the same `ProviderDriver` value contract. * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` - * binaries. That keeps the assertions focused on registry routing - * behaviour rather than the runtime details of each provider. + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / + * `opencode` / `kiro-cli` binaries. That keeps the assertions focused on + * registry routing behaviour rather than the runtime details of each + * provider. */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -29,6 +30,7 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, + type KiroSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -53,6 +55,7 @@ import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; +import { KiroDriver } from "../Drivers/KiroDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; @@ -142,6 +145,14 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeKiroConfig = (overrides: Partial): KiroSettings => ({ + enabled: false, + binaryPath: "kiro-cli", + agent: "", + customModels: [], + ...overrides, +}); + const makeTildeProviderFixtures = Effect.fn( "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", )(function* () { @@ -469,12 +480,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const kiroId = ProviderInstanceId.make("kiro_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const kiroDriverKind = ProviderDriverKind.make("kiro"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -510,10 +523,16 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [kiroId]: { + driver: kiroDriverKind, + displayName: "Kiro", + enabled: false, + config: makeKiroConfig({}), + }, }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver, KiroDriver], configMap, }); @@ -523,9 +542,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, kiroId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -536,16 +555,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const kiro = yield* registry.getInstance(kiroId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(kiro?.driverKind).toBe(kiroDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); + expect(kiro?.displayName).toBe("Kiro"); // Every instance owns its own set of closures — no sharing across // drivers. `adapter` / `textGeneration` / `snapshot` are all @@ -558,6 +580,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.adapter, grok!.adapter, openCode!.adapter, + kiro!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ @@ -566,6 +589,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.textGeneration, grok!.textGeneration, openCode!.textGeneration, + kiro!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); const snapshots = [ @@ -574,6 +598,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.snapshot, grok!.snapshot, openCode!.snapshot, + kiro!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -620,6 +645,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(openCodeSnapshot.continuation?.groupKey).toBe( `${openCodeDriverKind}:instance:${openCodeId}`, ); + + const kiroSnapshot = yield* kiro!.snapshot.getSnapshot; + expect(kiroSnapshot.instanceId).toBe(kiroId); + expect(kiroSnapshot.driver).toBe(kiroDriverKind); + expect(kiroSnapshot.enabled).toBe(false); + expect(kiroSnapshot.continuation?.groupKey).toBe(`${kiroDriverKind}:instance:${kiroId}`); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/provider/Services/KiroAdapter.ts b/apps/server/src/provider/Services/KiroAdapter.ts new file mode 100644 index 000000000000..480a1d11cc83 --- /dev/null +++ b/apps/server/src/provider/Services/KiroAdapter.ts @@ -0,0 +1,16 @@ +/** + * KiroAdapter — shape type for the Kiro provider adapter. + * + * The driver model ({@link ../Drivers/KiroDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module KiroAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * KiroAdapterShape — per-instance Kiro adapter contract. + */ +export interface KiroAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..e57e67b399f5 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -92,7 +92,8 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** Omit for agents that own their login and reject `authenticate` (for example Kiro). */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; /** Extra workspace roots the agent may read and write besides `cwd`. */ readonly additionalDirectories?: ReadonlyArray; @@ -699,15 +700,17 @@ export const make = ( const startOnce = Effect.gen(function* () { const initializeResult = yield* sendInitialize; - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId !== undefined) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/acp/KiroAcpCliProbe.test.ts b/apps/server/src/provider/acp/KiroAcpCliProbe.test.ts new file mode 100644 index 000000000000..116ba83ac24a --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpCliProbe.test.ts @@ -0,0 +1,87 @@ +/** + * Optional integration check against a real `kiro-cli acp` install. + * Enable with: T3_KIRO_ACP_PROBE=1 vp test run KiroAcpCliProbe + * Set T3_KIRO_LIVE_TURN=1 to also send a small prompt to the real model. + * + * The probe assumes the user has previously run `kiro-cli login`. Kiro + * advertises no ACP auth methods, so an unauthenticated CLI fails at + * `session/new` rather than at an `authenticate` step. + */ +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 FileSystem from "effect/FileSystem"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe, expect } from "vite-plus/test"; + +import { makeKiroAcpRuntime } from "./KiroAcpSupport.ts"; + +const makeProbeRuntime = (options?: { readonly trustNoTools?: boolean }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + return yield* makeKiroAcpRuntime({ + kiroSettings: { binaryPath: "kiro-cli", agent: "" }, + environment: process.env, + childProcessSpawner, + cwd, + ...options, + clientInfo: { name: "t3-kiro-probe", version: "0.0.0" }, + }); + }); + +describe.runIf(process.env.T3_KIRO_ACP_PROBE === "1")("Kiro ACP CLI probe", () => { + it.effect("initializes without authenticate and advertises no auth methods", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime(); + const started = yield* runtime.start(); + expect(started.initializeResult.authMethods ?? []).toEqual([]); + expect(started.initializeResult.agentCapabilities?.loadSession).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/new advertises a model catalog and accepts session/set_model", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime(); + const started = yield* runtime.start(); + const models = started.sessionSetupResult.models; + expect(typeof started.sessionId).toBe("string"); + expect(models?.currentModelId).toBe("auto"); + expect(models?.availableModels.length ?? 0).toBeGreaterThan(1); + const alternate = models?.availableModels.find((model) => model.modelId !== "auto"); + expect(alternate).toBeDefined(); + if (!alternate) return; + yield* runtime.setSessionModel(alternate.modelId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.skipIf(process.env.T3_KIRO_LIVE_TURN !== "1")( + "finishes a real Kiro turn and streams its answer", + () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime({ trustNoTools: true }); + yield* runtime.start(); + const chunks: string[] = []; + const events = yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + if (event._tag === "ContentDelta") { + chunks.push(event.text); + } + return Effect.void; + }).pipe(Effect.forkChild); + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "Reply exactly KIRO_T3_OK. Do not use any tools." }], + }); + yield* runtime.drainEvents; + expect(result.stopReason).toBe("end_turn"); + expect(chunks.join("")).toContain("KIRO_T3_OK"); + yield* Fiber.interrupt(events); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/KiroAcpSupport.test.ts b/apps/server/src/provider/acp/KiroAcpSupport.test.ts new file mode 100644 index 000000000000..18288f4e0799 --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpSupport.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { + applyKiroAcpModelSelection, + buildKiroAcpSpawnInput, + kiroAcpSpawnArgs, + resolveKiroAcpBaseModelId, +} from "./KiroAcpSupport.ts"; + +describe("resolveKiroAcpBaseModelId", () => { + it("falls back to Kiro's auto routing and trims custom ids", () => { + expect(resolveKiroAcpBaseModelId(undefined)).toBe("auto"); + expect(resolveKiroAcpBaseModelId(" ")).toBe("auto"); + expect(resolveKiroAcpBaseModelId(" claude-sonnet-5 ")).toBe("claude-sonnet-5"); + }); +}); + +describe("kiroAcpSpawnArgs", () => { + it("starts the ACP agent with Kiro's own trust settings by default", () => { + expect(kiroAcpSpawnArgs(undefined)).toEqual(["acp"]); + expect(kiroAcpSpawnArgs({ binaryPath: "", agent: "" }, "approval-required")).toEqual(["acp"]); + expect(kiroAcpSpawnArgs(undefined, "auto-accept-edits")).toEqual(["acp"]); + expect(kiroAcpSpawnArgs(undefined, "auto")).toEqual(["acp"]); + }); + + it("trusts every tool natively for Full access", () => { + expect(kiroAcpSpawnArgs(undefined, "full-access")).toEqual(["acp", "--trust-all-tools"]); + }); + + it("selects the configured agent", () => { + expect(kiroAcpSpawnArgs({ binaryPath: "", agent: " kiro_planner " }, "full-access")).toEqual([ + "acp", + "--agent", + "kiro_planner", + "--trust-all-tools", + ]); + }); + + it("trusts no tools for text generation even in Full access", () => { + expect(kiroAcpSpawnArgs(undefined, "full-access", { trustNoTools: true })).toEqual([ + "acp", + "--trust-tools=", + ]); + }); +}); + +describe("buildKiroAcpSpawnInput", () => { + it("uses the configured binary and passes the environment through untouched", () => { + expect( + buildKiroAcpSpawnInput({ binaryPath: "/opt/kiro/kiro-cli", agent: "" }, "/tmp/project", { + HOME: "/Users/dev", + }), + ).toEqual({ + command: "/opt/kiro/kiro-cli", + args: ["acp"], + cwd: "/tmp/project", + env: { HOME: "/Users/dev" }, + }); + }); + + it("defaults to kiro-cli on PATH", () => { + expect(buildKiroAcpSpawnInput(undefined, "/tmp/project").command).toBe("kiro-cli"); + }); +}); + +describe("applyKiroAcpModelSelection", () => { + const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { + const modelCalls: Array = []; + const runtime = { + setSessionModel: (modelId: string) => + Effect.gen(function* () { + modelCalls.push(modelId); + if (failure) return yield* failure; + return {}; + }), + }; + return { runtime, modelCalls }; + }; + + it.effect("calls session/set_model when the requested model differs from current", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyKiroAcpModelSelection({ + runtime, + currentModelId: "auto", + requestedModelId: "claude-sonnet-5", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual(["claude-sonnet-5"]); + expect(result).toBe("claude-sonnet-5"); + }), + ); + + it.effect("skips the RPC when the model is unchanged or unspecified", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + expect( + yield* applyKiroAcpModelSelection({ + runtime, + currentModelId: "auto", + requestedModelId: "auto", + mapError: (cause) => cause.message, + }), + ).toBe("auto"); + expect( + yield* applyKiroAcpModelSelection({ + runtime, + currentModelId: "auto", + requestedModelId: undefined, + mapError: (cause) => cause.message, + }), + ).toBe("auto"); + expect(modelCalls).toEqual([]); + }), + ); + + it.effect("maps set_model failures through mapError", () => + Effect.gen(function* () { + const { runtime } = makeRecordingRuntime( + new EffectAcpErrors.AcpRequestError({ code: -32602, errorMessage: "unknown model" }), + ); + const error = yield* applyKiroAcpModelSelection({ + runtime, + currentModelId: "auto", + requestedModelId: "nope", + mapError: (cause) => `mapped: ${cause.message}`, + }).pipe(Effect.flip); + expect(error).toContain("mapped:"); + }), + ); +}); diff --git a/apps/server/src/provider/acp/KiroAcpSupport.ts b/apps/server/src/provider/acp/KiroAcpSupport.ts new file mode 100644 index 000000000000..f456a038a455 --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpSupport.ts @@ -0,0 +1,135 @@ +import { + KIRO_DEFAULT_MODEL, + type KiroSettings, + ProviderDriverKind, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); + +type KiroAcpRuntimeKiroSettings = Pick; + +interface KiroAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly kiroSettings: KiroAcpRuntimeKiroSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; + /** Trust no Kiro tools, so every tool call must go through T3's permission handler. */ + readonly trustNoTools?: boolean; +} + +/** + * `kiro-cli acp` argv for a T3 runtime mode. Kiro has no auto-review tier, so + * Supervised, Auto-accept edits, and Auto all leave Kiro's own trust settings + * in place and route the remaining permission requests through T3. Full access + * trusts every tool natively so the agent never blocks on a request. + */ +export function kiroAcpSpawnArgs( + kiroSettings: KiroAcpRuntimeKiroSettings | null | undefined, + runtimeMode?: RuntimeMode, + options?: { readonly trustNoTools?: boolean }, +): ReadonlyArray { + const agent = kiroSettings?.agent?.trim(); + return [ + "acp", + ...(agent ? ["--agent", agent] : []), + ...(options?.trustNoTools + ? ["--trust-tools="] + : runtimeMode === "full-access" + ? ["--trust-all-tools"] + : []), + ]; +} + +export function buildKiroAcpSpawnInput( + kiroSettings: KiroAcpRuntimeKiroSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, + options?: { readonly trustNoTools?: boolean }, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: kiroSettings?.binaryPath || "kiro-cli", + args: [...kiroAcpSpawnArgs(kiroSettings, runtimeMode, options)], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +/** + * Kiro owns its login (`kiro-cli login`) and advertises no ACP auth methods; + * its agent answers `authenticate` with "Method not found", so the runtime + * must skip that step. + */ +export const makeKiroAcpRuntime = ( + input: KiroAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildKiroAcpSpawnInput( + input.kiroSettings, + input.cwd, + input.environment, + input.runtimeMode, + { trustNoTools: input.trustNoTools === true }, + ), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveKiroAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : KIRO_DEFAULT_MODEL; + return normalizeModelSlug(base, KIRO_DRIVER_KIND) ?? KIRO_DEFAULT_MODEL; +} + +export function currentKiroModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +/** Switches the session model through `session/set_model` only when it differs from the current one. */ +export function applyKiroAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const requestedModelId = input.requestedModelId?.trim() || undefined; + if (requestedModelId === undefined || requestedModelId === input.currentModelId) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(requestedModelId)); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..e537e9b2959f 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { KiroDriver, type KiroDriverEnv } from "./Drivers/KiroDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -38,6 +39,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | KiroDriverEnv | OpenCodeDriverEnv | AntigravityDriverEnv; @@ -51,6 +53,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ "claudeAgent", "cursor", "grok", + "kiro", "opencode", "antigravity", ]; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 208d75fb6517..12321efb36a8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1043,6 +1043,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { grok: { enabled: false, }, + kiro: { + enabled: false, + }, opencode: { enabled: false, serverUrl: "http://127.0.0.1:4096", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 50f8649eaacb..ab7c12bb69df 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -263,6 +263,7 @@ const PersistedOptionalProviderSettings = Schema.Struct({ Schema.Struct({ cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + kiro: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), }), ), @@ -291,6 +292,7 @@ function restoreUsedProviders( instance.enabled === undefined && (instance.driver === "cursor" || instance.driver === "grok" || + instance.driver === "kiro" || instance.driver === "opencode") && usedProviderInstances.has(instanceId) ? { ...instance, enabled: true } @@ -310,6 +312,10 @@ function restoreUsedProviders( ...settings.providers.grok, enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), }, + kiro: { + ...settings.providers.kiro, + enabled: persisted.providers?.kiro?.enabled ?? usedProviders.has("kiro"), + }, opencode: { ...settings.providers.opencode, enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), @@ -367,6 +373,7 @@ const PERSISTED_SERVER_SETTINGS_DEFAULTS = { ...DEFAULT_SERVER_SETTINGS.providers, cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, + kiro: { ...DEFAULT_SERVER_SETTINGS.providers.kiro, enabled: undefined }, opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, }, }; @@ -592,13 +599,13 @@ const make = Effect.gen(function* () { provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM projection_thread_sessions - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'grok', 'kiro', 'opencode') UNION SELECT DISTINCT provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM provider_session_runtime - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'grok', 'kiro', 'opencode') `.pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/textGeneration/KiroTextGeneration.ts b/apps/server/src/textGeneration/KiroTextGeneration.ts new file mode 100644 index 000000000000..b2440325661b --- /dev/null +++ b/apps/server/src/textGeneration/KiroTextGeneration.ts @@ -0,0 +1,266 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type KiroSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyKiroAcpModelSelection, + currentKiroModelIdFromSessionSetup, + makeKiroAcpRuntime, + resolveKiroAcpBaseModelId, +} from "../provider/acp/KiroAcpSupport.ts"; + +const KIRO_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Text generation over a throwaway `kiro-cli acp` session. The agent starts + * with no trusted tools and no permission handler, so any tool call it + * attempts is rejected and the reply stays text-only. + */ +export const makeKiroTextGeneration = Effect.fn("makeKiroTextGeneration")(function* ( + kiroSettings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runKiroJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveKiroAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeKiroAcpRuntime({ + kiroSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + trustNoTools: true, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* applyKiroAcpModelSelection({ + runtime, + currentModelId: currentKiroModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Kiro ACP model for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(KIRO_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Kiro ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Kiro ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Kiro ACP request was cancelled." + : "Kiro agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kiro agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Kiro ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("KiroTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runKiroJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("KiroTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runKiroJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("KiroTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runKiroJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("KiroTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runKiroJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 27fc28df7bf7..6d8f6bc490c6 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -10,7 +10,13 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as ThreadTitleLinks from "./ThreadTitleLinks.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "kiro" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..710fe374a58b 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -5,6 +5,7 @@ import { CursorIcon, GrokIcon, Icon, + KiroIcon, OpenAI, OpenCodeIcon, } from "../Icons"; @@ -15,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("kiro")]: KiroIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..332a0f40dd9c 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -4,6 +4,7 @@ import { CodexSettings, CursorSettings, GrokSettings, + KiroSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; @@ -14,6 +15,7 @@ import { CursorIcon, GrokIcon, type Icon, + KiroIcon, OpenAI, OpenCodeIcon, } from "../Icons"; @@ -70,6 +72,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("kiro"), + label: "Kiro", + icon: KiroIcon, + badgeLabel: "Early Access", + settingsSchema: KiroSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 19268e2cdaed..58865ee8614f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -302,7 +302,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "provider-update-checks", title: "Provider update checks", to: "/settings/general", - searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], + searchTerms: ["installed cli versions newer available codex claude cursor grok kiro opencode"], scope: "environment-defaults", }, { @@ -455,7 +455,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", + "agents cli codex claude cursor grok kiro opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, { diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..99847bf2dec0 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -42,6 +42,12 @@ Opening a provider session can start MCP servers, run hooks, or launch a login b session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for explicit setup or model refresh; background checks use initialization only. +Kiro owns its login entirely: `kiro-cli acp` advertises no ACP auth methods and answers +`authenticate` with "Method not found", so the [shared ACP runtime](../../apps/server/src/provider/acp/AcpSessionRuntime.ts) +skips that call when an adapter passes no `authMethodId`. The +[Kiro probe](../../apps/server/src/provider/Layers/KiroProvider.ts) reads login state and the model +catalog from `whoami` and `chat --list-models`, never from an agent session. + [Antigravity sign-in](../../apps/server/src/provider/AntigravityAuth.ts) belongs to the initiating T3 auth session. The client carries the return URL back to the environment because the provider's loopback listener may be on another machine. Forward only the callback for the owned pending flow; diff --git a/docs/user/install.md b/docs/user/install.md index a4ed171bd986..8f7597ec9175 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -97,6 +97,7 @@ computer. | Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | | Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | | Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| Kiro | Install [Kiro CLI](https://kiro.dev/cli), then run `kiro-cli login`. | | OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | | Antigravity | Install and sign in with Google from T3 Code's provider settings. | diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index dfc0c448c858..7c2d7a0e8c46 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -24,8 +24,9 @@ Providers enforce permissions differently. Some read-only actions can proceed in **Auto** uses automatic review on Codex, Claude, and Cursor; providers without an equivalent, including OpenCode and Antigravity, fall back to asking. -For Grok, **Always allow this session** remembers the matching command or tool input. Other -actions still require approval. +For Grok and Kiro, **Always allow this session** remembers the matching command or tool input. +Other actions still require approval. Kiro has no automatic review, so **Auto-accept edits** and +**Auto** behave like **Supervised** apart from the tools Kiro's own agent config already trusts. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 0882aef51d05..f8c554131594 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -147,6 +147,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-6-astra"; @@ -164,6 +165,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; +/** Kiro's server-side model routing. A real model id the ACP accepts, unlike the Grok product slug. */ +export const KIRO_DEFAULT_MODEL = "auto"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { @@ -172,6 +175,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [KIRO_DRIVER_KIND]: "Kiro", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7b3715d704be..9a3457bce01e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -730,6 +730,40 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const KiroSettings = makeProviderSettingsSchema( + { + // Off by default like Cursor and Grok. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kiro-cli").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kiro CLI binary.", + providerSettingsForm: { placeholder: "kiro-cli", clearWhenEmpty: "omit" }, + }), + ), + agent: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Agent", + description: + "Kiro agent that new sessions start with. Leave empty to use Kiro's default agent.", + providerSettingsForm: { placeholder: "kiro_default", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "agent"], + }, +); +export type KiroSettings = typeof KiroSettings.Type; + /** * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in * in the browser. The API key and Agent Platform methods take credentials from @@ -1168,6 +1202,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + kiro: KiroSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -1325,6 +1360,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const KiroSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + agent: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), +}); + const AntigravitySettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), authMethod: Schema.optionalKey(AntigravityAuthMethod), @@ -1415,6 +1457,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + kiro: Schema.optionalKey(KiroSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), }),