From f143ae957a73401cea0f20cd5d8fb66929f49daa Mon Sep 17 00:00:00 2001 From: Adithya Sakaray Date: Fri, 17 Jul 2026 19:06:58 +0530 Subject: [PATCH 1/5] feat: add Kiro CLI ACP provider Add Kiro as a first-party provider over the existing ACP lifecycle so streaming, permissions, cancellation, resume, and model switching stay shared with Grok rather than forked into a third session state machine. - Wire Kiro driver/settings/models and optional ACP authenticate skip - Reuse hardened ACP adapter path with Kiro spawn (`kiro-cli acp`) - Scope mock ACP cancellation to the active prompt for follow-up turns - Add focused Kiro unit/integration coverage --- apps/server/scripts/acp-mock-agent.ts | 15 +- .../server/src/provider/Drivers/KiroDriver.ts | 160 ++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 62 ++-- .../src/provider/Layers/KiroAdapter.test.ts | 177 +++++++++++ .../server/src/provider/Layers/KiroAdapter.ts | 31 ++ .../src/provider/Layers/KiroProvider.test.ts | 36 +++ .../src/provider/Layers/KiroProvider.ts | 300 ++++++++++++++++++ .../src/provider/Services/KiroAdapter.ts | 9 + .../src/provider/acp/AcpSessionRuntime.ts | 24 +- .../src/provider/acp/KiroAcpSupport.test.ts | 26 ++ .../server/src/provider/acp/KiroAcpSupport.ts | 80 +++++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/textGeneration/GrokTextGeneration.ts | 31 +- .../src/textGeneration/KiroTextGeneration.ts | 18 ++ .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.ts | 32 ++ 19 files changed, 988 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/provider/Drivers/KiroDriver.ts create mode 100644 apps/server/src/provider/Layers/KiroAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/KiroAdapter.ts create mode 100644 apps/server/src/provider/Layers/KiroProvider.test.ts create mode 100644 apps/server/src/provider/Layers/KiroProvider.ts create mode 100644 apps/server/src/provider/Services/KiroAdapter.ts create mode 100644 apps/server/src/provider/acp/KiroAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/KiroAcpSupport.ts create mode 100644 apps/server/src/textGeneration/KiroTextGeneration.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..ccf6f063264 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -55,7 +55,7 @@ let currentContext = "272k"; let currentFast = false; let promptCount = 0; let overlappingFirstPromptId: string | undefined; -const cancelledSessions = new Set(); +const cancelledSessions = new Map(); function promptIdFromRequestMeta( request: Pick, @@ -436,7 +436,7 @@ const program = Effect.gen(function* () { yield* agent.handleCancel(({ sessionId }) => Effect.gen(function* () { const cancelledSessionId = String(sessionId ?? "mock-session-1"); - cancelledSessions.add(cancelledSessionId); + cancelledSessions.set(cancelledSessionId, promptCount); if (emitLateUpdateAfterCancel) { yield* Effect.sleep("50 millis"); yield* Effect.sync(() => { @@ -456,6 +456,7 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + const currentPromptCount = promptCount; if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { yield* Effect.sleep(`${promptDelayMs} millis`); @@ -684,9 +685,13 @@ const program = Effect.gen(function* () { ], }); - const cancelled = - cancelledSessions.delete(requestedSessionId) || - permission.outcome.outcome === "cancelled"; + const cancelledPromptCount = cancelledSessions.get(requestedSessionId); + const cancelledBySessionState = cancelledPromptCount === currentPromptCount; + if (cancelledPromptCount !== undefined && cancelledPromptCount <= currentPromptCount) { + cancelledSessions.delete(requestedSessionId); + } + const cancelledByPermission = permission.outcome.outcome === "cancelled"; + const cancelled = cancelledBySessionState || cancelledByPermission; yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, diff --git a/apps/server/src/provider/Drivers/KiroDriver.ts b/apps/server/src/provider/Drivers/KiroDriver.ts new file mode 100644 index 00000000000..caf30826941 --- /dev/null +++ b/apps/server/src/provider/Drivers/KiroDriver.ts @@ -0,0 +1,160 @@ +import { KiroSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +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 { 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 type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeKiroSettings = Schema.decodeSync(KiroSettings); +const DRIVER_KIND = ProviderDriverKind.make("kiro"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type KiroDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft) => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +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, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies KiroSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + 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>({ + maintenanceCapabilities, + 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, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).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/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c22b2180183..f22da9adb46 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -71,7 +71,7 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); -const PROVIDER = ProviderDriverKind.make("grok"); +const GROK_PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; function encodeJsonStringForDiagnostics(input: unknown): string | undefined { @@ -84,6 +84,14 @@ export interface GrokAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + /** + * Standard ACP providers can reuse the hardened Grok lifecycle adapter by + * supplying their identity, runtime factory, and model normalization. + */ + readonly provider?: ProviderDriverKind; + readonly providerDisplayName?: string; + readonly makeAcpRuntime?: typeof makeGrokAcpRuntime; + readonly resolveModelId?: (model: string | null | undefined) => string | undefined; } interface PendingApproval { @@ -226,7 +234,11 @@ export function grokPromptSettlementBelongsToContext(input: { export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); + const PROVIDER = options?.provider ?? GROK_PROVIDER; + const providerDisplayName = options?.providerDisplayName ?? "Grok"; + const makeAcpRuntime = options?.makeAcpRuntime ?? makeGrokAcpRuntime; + const resolveModelId = options?.resolveModelId ?? resolveGrokAcpBaseModelId; + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make(PROVIDER); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -252,7 +264,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte new ProviderAdapterRequestError({ provider: PROVIDER, method: "crypto/randomUUIDv4", - detail: "Failed to generate Grok runtime identifier.", + detail: `Failed to generate ${providerDisplayName} runtime identifier.`, cause, }), ), @@ -264,7 +276,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.mapError( (cause) => new EffectAcpErrors.AcpTransportError({ - detail: "Failed to process Grok ACP callback.", + detail: `Failed to process ${providerDisplayName} ACP callback.`, cause, }), ), @@ -453,7 +465,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }).pipe( Effect.catchCause((cause) => - Effect.logWarning("Failed to write native Grok notification log.", { + Effect.logWarning(`Failed to write native ${providerDisplayName} notification log.`, { cause, threadId, method, @@ -547,7 +559,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const cwd = path.resolve(input.cwd.trim()); - const grokModelSelection = + const providerModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { @@ -570,7 +582,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeGrokAcpRuntime({ + const acp = yield* makeAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, @@ -734,8 +746,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), ); - const requestedStartModelId = grokModelSelection?.model - ? resolveGrokAcpBaseModelId(grokModelSelection.model) + const requestedStartModelId = providerModelSelection?.model + ? resolveModelId(providerModelSelection.model) : undefined; const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, @@ -744,6 +756,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const displayBoundModel = resolveModelId(boundModelId); const now = yield* nowIso; const session: ProviderSession = { @@ -752,7 +765,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte status: "ready", runtimeMode: input.runtimeMode, cwd, - ...(boundModelId ? { model: resolveGrokAcpBaseModelId(boundModelId) } : {}), + ...(displayBoundModel ? { model: displayBoundModel } : {}), threadId: input.threadId, resumeCursor: { schemaVersion: GROK_RESUME_VERSION, @@ -873,7 +886,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), ).pipe( Effect.catch((cause) => - Effect.logError("Failed to process Grok runtime notification.", { cause }), + Effect.logError(`Failed to process ${providerDisplayName} runtime notification.`, { + cause, + }), ), Effect.forkChild, ); @@ -894,7 +909,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { state: "ready", reason: "Grok ACP session ready" }, + payload: { + state: "ready", + reason: `${providerDisplayName} ACP session ready`, + }, }); yield* offerRuntimeEvent({ type: "thread.started", @@ -939,7 +957,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? input.modelSelection : undefined; const requestedTurnModelId = turnModelSelection?.model - ? resolveGrokAcpBaseModelId(turnModelSelection.model) + ? resolveModelId(turnModelSelection.model) : undefined; const currentModelId = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, @@ -997,9 +1015,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } ctx.currentModelId = currentModelId; - const displayModel = currentModelId - ? resolveGrokAcpBaseModelId(currentModelId) - : undefined; + const displayModel = currentModelId ? resolveModelId(currentModelId) : undefined; for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; } @@ -1012,7 +1028,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: "Grok prompt was interrupted during preparation.", + detail: `${providerDisplayName} prompt was interrupted during preparation.`, }); } if (steeringTurnId === undefined) { @@ -1052,7 +1068,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { - errorMessage: "Grok prompt preparation failed.", + errorMessage: `${providerDisplayName} prompt preparation failed.`, emitTurnCompletion: false, }); }), @@ -1101,7 +1117,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte prepared.turnId, prepared.acpSessionId, { - errorMessage: "Grok session changed before the turn completed.", + errorMessage: `${providerDisplayName} session changed before the turn completed.`, settleAllPrompts: true, }, ); @@ -1109,7 +1125,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: "Grok session changed before the turn completed.", + detail: `${providerDisplayName} session changed before the turn completed.`, }); } // Keep prompt settlement atomic with respect to Stop and steering. @@ -1224,7 +1240,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte prepared.turnId, prepared.acpSessionId, { - errorMessage: "Grok session changed before the turn completed.", + errorMessage: `${providerDisplayName} session changed before the turn completed.`, settleAllPrompts: true, }, ); @@ -1263,7 +1279,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* withThreadLock( input.threadId, settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { - errorMessage: errorMessage ?? "Grok prompt request failed.", + errorMessage: errorMessage ?? `${providerDisplayName} prompt request failed.`, }), ); }).pipe(Effect.catch(() => Effect.void)), @@ -1409,7 +1425,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "thread/rollback", - detail: "Grok ACP sessions do not support provider-side rollback yet.", + detail: `${providerDisplayName} ACP sessions do not support provider-side rollback yet.`, }); }); 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 00000000000..0a8b74de2a1 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroAdapter.test.ts @@ -0,0 +1,177 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ApprovalRequestId, + KiroSettings, + ProviderDriverKind, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +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 Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { ServerConfig } from "../../config.ts"; +import { makeKiroAdapter } 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"); + +async function makeMockKiroWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kiro-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-kiro-cli.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + await NodeFSP.writeFile( + wrapperPath, + `#!/bin/sh\n${envExports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@"\n`, + "utf8", + ); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +function waitForFileContent(filePath: string, attempts = 40): Effect.Effect { + const attempt = (remaining: number): Effect.Effect => + Effect.gen(function* () { + if (remaining <= 0) return yield* Effect.die(`Timed out waiting for ${filePath}`); + const content = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + if (content.trim()) return content; + yield* Effect.sleep("25 millis"); + return yield* attempt(remaining - 1); + }); + return attempt(attempts); +} + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-kiro-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(testLayer)("KiroAdapter", (it) => { + it.effect("starts a session, sends a message, and streams the response", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-message"); + const binaryPath = yield* Effect.promise(() => makeMockKiroWrapper()); + const adapter = yield* makeKiroAdapter(decodeKiroSettings({ binaryPath })); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kiro"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello kiro", attachments: [] }); + + assert.equal(session.provider, "kiro"); + assert.include( + events.map((event) => event.type), + "content.delta", + ); + assert.include( + events.map((event) => event.type), + "turn.completed", + ); + assert.equal( + events.find((event) => event.type === "content.delta")?.payload.delta, + "hello from mock", + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stops an in-flight message and accepts a follow-up", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-interrupt"); + const binaryPath = yield* Effect.promise(() => + makeMockKiroWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const adapter = yield* makeKiroAdapter(decodeKiroSettings({ binaryPath })); + const events: ProviderRuntimeEvent[] = []; + const firstApproval = yield* Deferred.make(); + const secondApproval = yield* Deferred.make(); + let approvalCount = 0; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type !== "request.opened") return; + approvalCount += 1; + const deferred = approvalCount === 1 ? firstApproval : secondApproval; + yield* Deferred.succeed(deferred, ApprovalRequestId.make(String(event.requestId))).pipe( + Effect.ignore, + ); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kiro"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const firstTurn = yield* adapter + .sendTurn({ threadId, input: "stop this", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstApproval); + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(firstTurn); + const followUp = yield* adapter + .sendTurn({ threadId, input: "continue", attachments: [] }) + .pipe(Effect.forkChild); + yield* adapter.respondToRequest(threadId, yield* Deferred.await(secondApproval), "accept"); + yield* Fiber.join(followUp); + + const completed = events.filter((event) => event.type === "turn.completed"); + assert.deepEqual( + completed.map((event) => event.payload.state), + ["cancelled", "completed"], + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("closes the Kiro ACP child when the session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kiro-stop-session"); + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kiro-acp-exit-")), + ); + const exitLogPath = NodePath.join(dir, "exit.log"); + const binaryPath = yield* Effect.promise(() => + makeMockKiroWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath }), + ); + const adapter = yield* makeKiroAdapter(decodeKiroSettings({ binaryPath })); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kiro"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.stopSession(threadId); + + assert.include(yield* waitForFileContent(exitLogPath), "SIGTERM"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KiroAdapter.ts b/apps/server/src/provider/Layers/KiroAdapter.ts new file mode 100644 index 00000000000..79ab6b428c6 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroAdapter.ts @@ -0,0 +1,31 @@ +import { type KiroSettings, ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; + +import { makeKiroAcpRuntime, resolveKiroAcpModelId } from "../acp/KiroAcpSupport.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { makeGrokAdapter } from "./GrokAdapter.ts"; + +export interface KiroAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +/** + * Kiro uses standard ACP session, model, permission, streaming, and + * cancellation methods, so it shares the existing hardened ACP lifecycle + * implementation instead of maintaining a third copy of that state machine. + */ +export function makeKiroAdapter(kiroSettings: KiroSettings, options?: KiroAdapterLiveOptions) { + return makeGrokAdapter(kiroSettings, { + ...options, + provider: ProviderDriverKind.make("kiro"), + providerDisplayName: "Kiro", + resolveModelId: resolveKiroAcpModelId, + makeAcpRuntime: ({ grokSettings: _grokSettings, ...input }) => + makeKiroAcpRuntime({ + ...input, + kiroSettings, + }), + }); +} 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 00000000000..bec0facfb96 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroProvider.test.ts @@ -0,0 +1,36 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { KiroSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { buildInitialKiroProviderSnapshot, checkKiroProviderStatus } from "./KiroProvider.ts"; + +const decodeKiroSettings = Schema.decodeSync(KiroSettings); + +describe("buildInitialKiroProviderSnapshot", () => { + it.effect("returns a pending enabled snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKiroProviderSnapshot(decodeKiroSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Checking Kiro"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); + }), + ); +}); + +it.layer(NodeServices.layer)("checkKiroProviderStatus", (it) => { + it.effect("reports a missing Kiro CLI binary", () => + Effect.gen(function* () { + const snapshot = yield* checkKiroProviderStatus( + decodeKiroSettings({ + binaryPath: "/definitely/not/installed/kiro-cli", + }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("not installed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KiroProvider.ts b/apps/server/src/provider/Layers/KiroProvider.ts new file mode 100644 index 00000000000..383f73da7db --- /dev/null +++ b/apps/server/src/provider/Layers/KiroProvider.ts @@ -0,0 +1,300 @@ +import { + type KiroSettings, + type ModelCapabilities, + ProviderDriverKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { makeKiroAcpRuntime, resolveKiroAcpModelId } from "../acp/KiroAcpSupport.ts"; + +const KIRO_PRESENTATION = { + displayName: "Kiro", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; +const PROVIDER = ProviderDriverKind.make("kiro"); +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const KIRO_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +const KIRO_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "default", + name: "Kiro default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function kiroModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = KIRO_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings( + builtInModels, + PROVIDER, + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +export function buildInitialKiroProviderSnapshot( + settings: KiroSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = kiroModelsFromSettings(settings.customModels); + if (!settings.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 buildKiroDiscoveredModels( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) return []; + const seen = new Set(); + return modelState.availableModels.flatMap((model) => { + const slug = resolveKiroAcpModelId(model.modelId) ?? model.modelId.trim(); + if (!slug || seen.has(slug)) return []; + seen.add(slug); + return [ + { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + ]; + }); +} + +const discoverKiroModelsViaAcp = ( + settings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeKiroAcpRuntime({ + kiroSettings: settings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + return buildKiroDiscoveredModels(started.sessionSetupResult.models); + }).pipe(Effect.scoped); + +const runKiroVersionCommand = ( + settings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "kiro-cli"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(function* ( + settings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = kiroModelsFromSettings(settings.customModels); + if (!settings.enabled) { + return yield* buildInitialKiroProviderSnapshot(settings); + } + + const versionResult = yield* runKiroVersionCommand(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: true, + 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: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but `kiro-cli --version` timed out.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverKiroModelsViaAcp(settings, environment).pipe( + Effect.timeoutOption(KIRO_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Kiro ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but ACP startup failed. Check authentication and logs.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Kiro ACP startup timed out after ${KIRO_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + + const discoveredModels = discoveryExit.value.value; + return buildServerProvider({ + presentation: KIRO_PRESENTATION, + enabled: true, + checkedAt, + models: + discoveredModels.length > 0 + ? kiroModelsFromSettings(settings.customModels, discoveredModels) + : fallbackModels, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +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 => + enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap(input.publishSnapshot), + Effect.catchCause((cause) => + Effect.logWarning("Kiro version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); diff --git a/apps/server/src/provider/Services/KiroAdapter.ts b/apps/server/src/provider/Services/KiroAdapter.ts new file mode 100644 index 00000000000..7864561100c --- /dev/null +++ b/apps/server/src/provider/Services/KiroAdapter.ts @@ -0,0 +1,9 @@ +/** + * KiroAdapter — shape type for the Kiro ACP provider adapter. + * + * @module KiroAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface KiroAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index bc2df3aa8d4..85ddc5299e2 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -67,7 +67,11 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * ACP authentication is optional. Agents such as Kiro authenticate outside + * the protocol and do not implement the `authenticate` method. + */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -529,15 +533,17 @@ export const make = ( acp.agent.initialize(initializePayload), ); - 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/KiroAcpSupport.test.ts b/apps/server/src/provider/acp/KiroAcpSupport.test.ts new file mode 100644 index 00000000000..3b1771c95ca --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpSupport.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { buildKiroAcpSpawnInput, resolveKiroAcpModelId } from "./KiroAcpSupport.ts"; + +describe("buildKiroAcpSpawnInput", () => { + it("spawns Kiro's documented ACP command", () => { + expect( + buildKiroAcpSpawnInput({ binaryPath: "/usr/local/bin/kiro-cli" }, "/tmp/project", { + KIRO_API_KEY: "secret", + }), + ).toEqual({ + command: "/usr/local/bin/kiro-cli", + args: ["acp"], + cwd: "/tmp/project", + env: { KIRO_API_KEY: "secret" }, + }); + }); +}); + +describe("resolveKiroAcpModelId", () => { + it("preserves the CLI default and normalizes explicit model ids", () => { + expect(resolveKiroAcpModelId(undefined)).toBeUndefined(); + expect(resolveKiroAcpModelId(" default ")).toBeUndefined(); + expect(resolveKiroAcpModelId(" claude-opus-4.6 ")).toBe("claude-opus-4.6"); + }); +}); diff --git a/apps/server/src/provider/acp/KiroAcpSupport.ts b/apps/server/src/provider/acp/KiroAcpSupport.ts new file mode 100644 index 00000000000..b76765634dd --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpSupport.ts @@ -0,0 +1,80 @@ +import { type KiroSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +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 * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); + +type KiroAcpRuntimeSettings = Pick; + +interface KiroAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly kiroSettings: KiroAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildKiroAcpSpawnInput( + kiroSettings: KiroAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: kiroSettings?.binaryPath || "kiro-cli", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +/** + * Kiro authenticates through its CLI login or `KIRO_API_KEY`; unlike Cursor + * and Grok, its ACP server does not expose the optional `authenticate` RPC. + */ +export const makeKiroAcpRuntime = ( + input: KiroAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildKiroAcpSpawnInput(input.kiroSettings, input.cwd, input.environment), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveKiroAcpModelId(model: string | null | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed || trimmed === "default") { + return undefined; + } + return normalizeModelSlug(trimmed, KIRO_DRIVER_KIND) ?? trimmed; +} + +export function currentKiroModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..781b276c6a6 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 type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | KiroDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray string | undefined; +} + export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + options?: GrokTextGenerationOptions, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const providerDisplayName = options?.providerDisplayName ?? "Grok"; + const makeAcpRuntime = options?.makeAcpRuntime ?? makeGrokAcpRuntime; + const resolveModelId = options?.resolveModelId ?? resolveGrokAcpBaseModelId; const runGrokJson = ({ operation, @@ -57,9 +67,9 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi modelSelection: ModelSelection; }): Effect.Effect => Effect.gen(function* () { - const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model); + const resolvedModel = resolveModelId(modelSelection.model); const outputRef = yield* Ref.make(""); - const runtime = yield* makeGrokAcpRuntime({ + const runtime = yield* makeAcpRuntime({ grokSettings, environment, childProcessSpawner: commandSpawner, @@ -88,7 +98,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi mapError: (cause) => new TextGenerationError({ operation, - detail: "Failed to set Grok ACP base model for text generation.", + detail: `Failed to set ${providerDisplayName} ACP base model for text generation.`, cause, }), }); @@ -102,7 +112,10 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Option.match({ onNone: () => Effect.fail( - new TextGenerationError({ operation, detail: "Grok ACP request timed out." }), + new TextGenerationError({ + operation, + detail: `${providerDisplayName} ACP request timed out.`, + }), ), onSome: (value) => Effect.succeed(value), }), @@ -112,7 +125,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi ? cause : new TextGenerationError({ operation, - detail: "Grok ACP request failed.", + detail: `${providerDisplayName} ACP request failed.`, cause, }), ), @@ -124,8 +137,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi operation, detail: promptResult.stopReason === "cancelled" - ? "Grok ACP request was cancelled." - : "Grok Agent returned empty output.", + ? `${providerDisplayName} ACP request was cancelled.` + : `${providerDisplayName} returned empty output.`, }); } @@ -136,7 +149,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.fail( new TextGenerationError({ operation, - detail: "Grok Agent returned invalid structured output.", + detail: `${providerDisplayName} returned invalid structured output.`, cause, }), ), @@ -148,7 +161,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi ? cause : new TextGenerationError({ operation, - detail: "Grok ACP text generation failed.", + detail: `${providerDisplayName} ACP text generation failed.`, cause, }), ), diff --git a/apps/server/src/textGeneration/KiroTextGeneration.ts b/apps/server/src/textGeneration/KiroTextGeneration.ts new file mode 100644 index 00000000000..660a97f87bb --- /dev/null +++ b/apps/server/src/textGeneration/KiroTextGeneration.ts @@ -0,0 +1,18 @@ +import type { KiroSettings } from "@t3tools/contracts"; + +import { makeKiroAcpRuntime, resolveKiroAcpModelId } from "../provider/acp/KiroAcpSupport.ts"; +import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; + +export const makeKiroTextGeneration = ( + settings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + makeGrokTextGeneration(settings, environment, { + providerDisplayName: "Kiro", + resolveModelId: resolveKiroAcpModelId, + makeAcpRuntime: ({ grokSettings: _grokSettings, ...input }) => + makeKiroAcpRuntime({ + ...input, + kiroSettings: settings, + }), + }); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..7b778dfdaa2 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, KiroIcon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("kiro")]: KiroIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..55e460d7c24 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,20 @@ import { CodexSettings, CursorSettings, GrokSettings, + KiroSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + KiroIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("kiro"), + label: "Kiro", + icon: KiroIcon, + badgeLabel: "Early Access", + settingsSchema: KiroSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..9722f46800f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("kiro"), + label: "Kiro", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..751da4171e2 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,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-5.4"; @@ -141,6 +142,7 @@ 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 773b4481844..4e05e72c205 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -305,6 +305,30 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const KiroSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + 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" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type KiroSettings = typeof KiroSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -398,6 +422,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({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -493,6 +518,12 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const KiroSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -522,6 +553,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), }), ), From 88498e14096031b7aa7aa6766a2c22c95fa7c252 Mon Sep 17 00:00:00 2001 From: Adithya Sakaray Date: Fri, 17 Jul 2026 19:17:42 +0530 Subject: [PATCH 2/5] fix: correct ACP model id resolution for Grok display and Kiro default --- apps/server/src/provider/Layers/GrokAdapter.ts | 5 ++++- apps/server/src/provider/acp/KiroAcpSupport.test.ts | 5 +++-- apps/server/src/provider/acp/KiroAcpSupport.ts | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index f22da9adb46..b59ee5b1048 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -756,7 +756,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); - const displayBoundModel = resolveModelId(boundModelId); + // Only resolve for display when ACP (or a switch) actually produced a + // model id. Calling resolveModelId(undefined) would fall through to + // provider defaults (e.g. "grok-build") and mis-advertise the session. + const displayBoundModel = boundModelId ? resolveModelId(boundModelId) : undefined; const now = yield* nowIso; const session: ProviderSession = { diff --git a/apps/server/src/provider/acp/KiroAcpSupport.test.ts b/apps/server/src/provider/acp/KiroAcpSupport.test.ts index 3b1771c95ca..1f81ccf29de 100644 --- a/apps/server/src/provider/acp/KiroAcpSupport.test.ts +++ b/apps/server/src/provider/acp/KiroAcpSupport.test.ts @@ -18,9 +18,10 @@ describe("buildKiroAcpSpawnInput", () => { }); describe("resolveKiroAcpModelId", () => { - it("preserves the CLI default and normalizes explicit model ids", () => { + it("keeps the selectable default slug and normalizes explicit model ids", () => { expect(resolveKiroAcpModelId(undefined)).toBeUndefined(); - expect(resolveKiroAcpModelId(" default ")).toBeUndefined(); + expect(resolveKiroAcpModelId("")).toBeUndefined(); + expect(resolveKiroAcpModelId(" default ")).toBe("default"); expect(resolveKiroAcpModelId(" claude-opus-4.6 ")).toBe("claude-opus-4.6"); }); }); diff --git a/apps/server/src/provider/acp/KiroAcpSupport.ts b/apps/server/src/provider/acp/KiroAcpSupport.ts index b76765634dd..c3cca4f466a 100644 --- a/apps/server/src/provider/acp/KiroAcpSupport.ts +++ b/apps/server/src/provider/acp/KiroAcpSupport.ts @@ -64,7 +64,9 @@ export const makeKiroAcpRuntime = ( export function resolveKiroAcpModelId(model: string | null | undefined): string | undefined { const trimmed = model?.trim(); - if (!trimmed || trimmed === "default") { + // Keep the advertised "default" slug as a real model id so selecting it after + // a custom model issues session/set_model instead of "do not switch" (undefined). + if (!trimmed) { return undefined; } return normalizeModelSlug(trimmed, KIRO_DRIVER_KIND) ?? trimmed; From 498eaf25bfc43edcb6e43623d01c079bc7d39540 Mon Sep 17 00:00:00 2001 From: Adithya Sakaray Date: Sun, 19 Jul 2026 12:05:50 +0530 Subject: [PATCH 3/5] fix(acp): stop resumed-session reply loss; add Kiro elicitation handling - AcpSessionRuntime: scope assistant item ids with a per-runtime nonce. A resumed session (session/load) reuses the ACP session id but a new runtime resets the segment counter, so assistant item ids collided with a prior turn's message. The upsert kept the stale created_at, sorting the resumed reply before the new prompt and making it invisible. - Shared ACP adapter: handle session/elicitation (form mode) via the existing user-input flow; advertise elicitation.form capability for Kiro. url-mode elicitations are declined (not renderable). - build-desktop-artifact: allow overriding appId/productName via T3CODE_DESKTOP_APP_ID / T3CODE_DESKTOP_PRODUCT_NAME for side-by-side builds. - Tests: resume-safety regression + updated cursor item-id assertions. --- .../src/provider/Layers/CursorAdapter.test.ts | 56 +++++- .../server/src/provider/Layers/GrokAdapter.ts | 56 ++++++ .../server/src/provider/acp/AcpElicitation.ts | 190 ++++++++++++++++++ .../src/provider/acp/AcpSessionRuntime.ts | 33 ++- .../server/src/provider/acp/KiroAcpSupport.ts | 11 + scripts/build-desktop-artifact.ts | 14 +- 6 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpElicitation.ts diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 5a59a022363..7e67aa0e71c 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -228,7 +228,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); - assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/); + assert.match(String(delta.itemId), /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/); } const assistantCompleted = runtimeEvents.find( @@ -250,6 +250,55 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect( + "assigns distinct assistant message ids across runtime instances that share an ACP session id", + () => + Effect.gen(function* () { + // Regression: a resumed session (session/load) keeps the same ACP + // session id but runs in a brand-new runtime whose assistant segment + // counter restarts at zero. Before the per-runtime nonce, both runtimes + // produced "assistant:mock-session-1:segment:0", so the resumed turn's + // reply upserted onto the earlier turn's message row — inheriting its + // stale created_at and sorting before the new prompt (reply invisible). + // The mock agent always negotiates "mock-session-1", so two sequential + // sessions reproduce that shared-session-id precondition. + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const captureAssistantItemId = (threadId: ThreadId) => + Effect.gen(function* () { + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + yield* adapter.sendTurn({ threadId, input: "hello mock", attachments: [] }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + yield* adapter.stopSession(threadId); + const delta = events.find((event) => event.type === "content.delta"); + assert.isDefined(delta); + return delta?.type === "content.delta" ? String(delta.itemId) : ""; + }); + + const firstItemId = yield* captureAssistantItemId(ThreadId.make("cursor-resume-safety-1")); + const secondItemId = yield* captureAssistantItemId(ThreadId.make("cursor-resume-safety-2")); + + assert.match(firstItemId, /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/); + assert.match(secondItemId, /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/); + assert.notEqual(firstItemId, secondItemId); + }), + ); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; @@ -687,7 +736,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (contentDelta?.type === "content.delta") { assert.equal(String(contentDelta.turnId), String(turn.turnId)); assert.equal(contentDelta.payload.delta, "hello from mock"); - assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0"); + assert.match( + String(contentDelta.itemId), + /^assistant:mock-session-1:[a-z0-9-]+:segment:0$/, + ); } }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index b59ee5b1048..c45a51fbf42 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -42,6 +42,11 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + extractElicitationQuestions, + makeElicitationAcceptedResponse, + makeElicitationDeclinedResponse, +} from "../acp/AcpElicitation.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, @@ -739,6 +744,57 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ), ); + yield* acp.handleElicitation((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/elicitation", params); + // Only form-mode elicitation is renderable through the + // structured user-input flow; url-mode cannot be surfaced here. + if (params.mode !== "form") { + return makeElicitationDeclinedResponse(); + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const resolution = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingUserInputs.set(requestId, { resolution }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + payload: { questions: extractElicitationQuestions(params) }, + raw: { + source: "acp.jsonrpc", + method: "session/elicitation", + payload: params, + }, + }); + const resolved = yield* Deferred.await(resolution); + pendingUserInputs.delete(requestId); + const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + payload: { answers: resolvedAnswers }, + raw: { + source: "acp.jsonrpc", + method: "session/elicitation", + payload: params, + }, + }); + return resolved._tag === "answered" + ? makeElicitationAcceptedResponse(params, resolved.answers) + : makeElicitationDeclinedResponse(); + }), + ), + ); return yield* acp.start(); }).pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/acp/AcpElicitation.ts b/apps/server/src/provider/acp/AcpElicitation.ts new file mode 100644 index 00000000000..9e21e1470b1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpElicitation.ts @@ -0,0 +1,190 @@ +/** + * AcpElicitation — maps the ACP `session/elicitation` request onto T3 Code's + * existing structured user-input flow (the same channel used for xAI's + * `ask_user_question`), and maps the collected answers back to an + * `ElicitationResponse`. + * + * Why this exists: agents such as Kiro can pause a turn to ask the user for a + * decision/confirmation via `session/elicitation`. Without a registered + * handler the ACP client answers `methodNotFound`, so the agent's turn stalls + * with no visible prompt. Routing elicitation through `user-input.requested` + * surfaces the prompt in the UI and lets the user answer it. + * + * Only the `form` elicitation mode is supported (we advertise `elicitation.form` + * as a client capability). `url` mode cannot be rendered here and is declined. + * + * @module provider/acp/AcpElicitation + */ +import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +/** Loose view over a single form property; the generated schema is a union. */ +interface ElicitationPropertyLike { + readonly type?: string | null; + readonly title?: string | null; + readonly description?: string | null; + readonly enum?: ReadonlyArray | null; + readonly oneOf?: ReadonlyArray<{ readonly const: string; readonly title: string }> | null; +} + +const HEADER_MAX_LENGTH = 200; + +function trimmed(value: string | null | undefined): string | undefined { + const text = value?.trim(); + return text && text.length > 0 ? text : undefined; +} + +function truncateHeader(value: string): string { + return value.length > HEADER_MAX_LENGTH ? `${value.slice(0, HEADER_MAX_LENGTH - 1)}…` : value; +} + +function formRequestedSchema(request: EffectAcpSchema.ElicitationRequest): { + properties: Record; + title?: string; + description?: string; +} { + if (request.mode !== "form") { + return { properties: {} }; + } + const schema = request.requestedSchema; + return { + properties: (schema.properties ?? {}) as Record, + ...(trimmed(schema.title) ? { title: trimmed(schema.title)! } : {}), + ...(trimmed(schema.description) ? { description: trimmed(schema.description)! } : {}), + }; +} + +function optionsForProperty( + property: ElicitationPropertyLike, +): ReadonlyArray<{ readonly label: string; readonly description: string }> { + if (property.type === "boolean") { + return [ + { label: "Yes", description: "Yes" }, + { label: "No", description: "No" }, + ]; + } + if (Array.isArray(property.oneOf) && property.oneOf.length > 0) { + return property.oneOf.flatMap((option) => { + const label = trimmed(option.title) ?? trimmed(option.const); + const description = trimmed(option.const) ?? trimmed(option.title); + return label && description ? [{ label, description }] : []; + }); + } + if (Array.isArray(property.enum) && property.enum.length > 0) { + return property.enum.flatMap((value) => { + const label = trimmed(value); + return label ? [{ label, description: label }] : []; + }); + } + // Free-form string / number: no fixed options (rendered as a text input). + return []; +} + +/** + * Projects a form-mode elicitation onto the structured user-input questions the + * UI already renders. Non-form modes yield no questions (declined upstream). + */ +export function extractElicitationQuestions( + request: EffectAcpSchema.ElicitationRequest, +): ReadonlyArray { + if (request.mode !== "form") { + return []; + } + const schema = formRequestedSchema(request); + const header = truncateHeader(trimmed(request.message) ?? schema.title ?? "Input requested"); + const keys = Object.keys(schema.properties); + if (keys.length === 0) { + return [ + { + id: "confirm", + header, + question: schema.description ?? trimmed(request.message) ?? "Please confirm to continue.", + options: [{ label: "Continue", description: "Proceed" }], + multiSelect: false, + }, + ]; + } + return keys.map((key) => { + const property = schema.properties[key]!; + return { + id: key, + header, + question: trimmed(property.description) ?? trimmed(property.title) ?? key, + options: optionsForProperty(property), + multiSelect: false, + } satisfies UserInputQuestion; + }); +} + +function firstAnswerString(answer: unknown): string | undefined { + if (Array.isArray(answer)) { + for (const entry of answer) { + const text = typeof entry === "string" ? trimmed(entry) : undefined; + if (text) { + return text; + } + } + return undefined; + } + if (typeof answer === "string") { + return trimmed(answer); + } + if (typeof answer === "boolean" || typeof answer === "number") { + return String(answer); + } + return undefined; +} + +function coerceAnswer( + property: ElicitationPropertyLike, + answer: unknown, +): EffectAcpSchema.ElicitationContentValue | undefined { + const value = firstAnswerString(answer); + if (value === undefined) { + return undefined; + } + if (property.type === "boolean") { + return value === "Yes" || value.toLowerCase() === "true"; + } + if (property.type === "number" || property.type === "integer") { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return undefined; + } + return property.type === "integer" ? Math.trunc(parsed) : parsed; + } + if (Array.isArray(property.oneOf) && property.oneOf.length > 0) { + const match = property.oneOf.find((option) => option.title === value || option.const === value); + return match ? match.const : value; + } + return value; +} + +/** + * Builds an `accept` elicitation response, coercing the collected answers into + * the primitive content values the requested schema expects. + */ +export function makeElicitationAcceptedResponse( + request: EffectAcpSchema.ElicitationRequest, + answers: ProviderUserInputAnswers, +): EffectAcpSchema.ElicitationResponse { + const content: Record = {}; + if (request.mode === "form") { + const properties = (request.requestedSchema.properties ?? {}) as Record< + string, + ElicitationPropertyLike + >; + for (const key of Object.keys(properties)) { + const value = coerceAnswer(properties[key]!, answers[key]); + if (value !== undefined) { + content[key] = value; + } + } + } + return { action: { action: "accept", content } }; +} + +/** Declines the elicitation (user cancelled, or an unsupported mode). */ +export function makeElicitationDeclinedResponse(): EffectAcpSchema.ElicitationResponse { + return { action: { action: "decline" } }; +} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 85ddc5299e2..9cdb2560e74 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -283,6 +283,17 @@ export const make = ( const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); + // Assistant item ids are derived from the ACP session id and a per-runtime + // segment counter. A resumed session (session/load) keeps the same session + // id but a brand-new runtime resets that counter to zero, so without an + // instance-scoped nonce a resumed turn's assistant item id would collide + // with a message from an earlier turn — the projection would then upsert the + // reply onto the stale row, keep its old created_at, and sort it before the + // new prompt (making the reply invisible). currentTimeNanos disambiguates + // instances across process restarts; the monotonic counter disambiguates + // instances created within the same process (and in tests where the clock is + // fixed). + const assistantIdNonce = `${(yield* Clock.currentTimeNanos).toString(36)}-${nextAcpRuntimeInstanceId().toString(36)}`; const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); @@ -391,6 +402,7 @@ export const make = ( modeStateRef, toolCallsRef, assistantSegmentRef, + assistantIdNonce, params: notification, }); }), @@ -840,12 +852,14 @@ const handleSessionUpdate = ({ modeStateRef, toolCallsRef, assistantSegmentRef, + assistantIdNonce, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; + readonly assistantIdNonce: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -892,6 +906,7 @@ const handleSessionUpdate = ({ const itemId = yield* ensureActiveAssistantSegment({ queue, assistantSegmentRef, + assistantIdNonce, sessionId: params.sessionId, }); yield* Queue.offer(queue, { @@ -930,16 +945,28 @@ function shouldEmitToolCallUpdate( return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } -const assistantItemId = (sessionId: string, segmentIndex: number) => - `assistant:${sessionId}:segment:${segmentIndex}`; +// Monotonic, process-scoped counter used to disambiguate assistant item id +// nonces for runtimes created within the same process (including tests, where +// the clock can be fixed). Combined with a wall-clock component it also stays +// unique across process restarts. +let acpRuntimeInstanceCounter = 0; +const nextAcpRuntimeInstanceId = (): number => { + acpRuntimeInstanceCounter += 1; + return acpRuntimeInstanceCounter; +}; + +const assistantItemId = (assistantIdNonce: string, sessionId: string, segmentIndex: number) => + `assistant:${sessionId}:${assistantIdNonce}:segment:${segmentIndex}`; const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, + assistantIdNonce, sessionId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; + readonly assistantIdNonce: string; readonly sessionId: string; }) => Ref.modify( @@ -948,7 +975,7 @@ const ensureActiveAssistantSegment = ({ if (current.activeItemId) { return [{ itemId: current.activeItemId }, current] as const; } - const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + const itemId = assistantItemId(assistantIdNonce, sessionId, current.nextSegmentIndex); return [ { itemId, diff --git a/apps/server/src/provider/acp/KiroAcpSupport.ts b/apps/server/src/provider/acp/KiroAcpSupport.ts index c3cca4f466a..82fea439d22 100644 --- a/apps/server/src/provider/acp/KiroAcpSupport.ts +++ b/apps/server/src/provider/acp/KiroAcpSupport.ts @@ -11,6 +11,16 @@ import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); +/** + * Kiro can pause a turn to ask the user for a decision via `session/elicitation`. + * Advertising the form capability lets Kiro use structured elicitation, which the + * shared ACP adapter surfaces through T3 Code's user-input flow. `url` mode is + * intentionally not advertised because the client cannot render URL elicitations. + */ +const KIRO_CLIENT_CAPABILITIES = { + elicitation: { form: {} }, +} satisfies NonNullable; + type KiroAcpRuntimeSettings = Pick; interface KiroAcpRuntimeInput extends Omit< @@ -51,6 +61,7 @@ export const makeKiroAcpRuntime = ( AcpSessionRuntime.layer({ ...input, spawn: buildKiroAcpSpawnInput(input.kiroSettings, input.cwd, input.environment), + clientCapabilities: KIRO_CLIENT_CAPABILITIES, }).pipe( Layer.provide( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index bac3ef3df85..4161b0102a1 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -30,7 +30,15 @@ import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; -const DESKTOP_APP_ID = "com.t3tools.t3code"; +// The bundle id and product name default to the official T3 Code identity. +// They can be overridden for personal/side-by-side builds (e.g. installing a +// custom build alongside the official app) without changing official output: +// T3CODE_DESKTOP_APP_ID=com.t3tools.t3code.adi \ +// T3CODE_DESKTOP_PRODUCT_NAME="T3 Code -Adi" \ +// vp run dist:desktop:dmg:arm64 +// A distinct app id is required for macOS to treat the build as a separate app +// rather than replacing the official one. +const DESKTOP_APP_ID = process.env.T3CODE_DESKTOP_APP_ID?.trim() || "com.t3tools.t3code"; const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); @@ -1358,6 +1366,10 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { } export function resolveDesktopProductName(version: string): string { + const override = process.env.T3CODE_DESKTOP_PRODUCT_NAME?.trim(); + if (override) { + return override; + } return resolveDesktopUpdateChannel(version) === "nightly" ? "T3 Code (Nightly)" : (desktopPackageJson.productName ?? "T3 Code"); From 8774e54e4d949901dc9d6f21706cff1de5810bfc Mon Sep 17 00:00:00 2001 From: Adithya Sakaray Date: Sun, 19 Jul 2026 12:46:20 +0530 Subject: [PATCH 4/5] fix(acp): address elicitation review feedback - coerceAnswer: only accept recognized boolean representations (yes/true -> true, no/false -> false); omit the key for unrecognized input instead of silently defaulting to false. - UserInputQuestion: add an optional flag (default false). Mark non-required elicitation properties optional so the form can be submitted without inventing values; unanswered optional questions are omitted from the response. - Honor optional questions in the web and mobile submission gates. - Tests: AcpElicitation coercion/optional coverage + web optional-field cases. --- apps/mobile/src/lib/threadActivity.ts | 5 + .../src/provider/acp/AcpElicitation.test.ts | 138 ++++++++++++++++++ .../server/src/provider/acp/AcpElicitation.ts | 20 ++- apps/web/src/pendingUserInput.test.ts | 48 ++++++ apps/web/src/pendingUserInput.ts | 7 +- packages/contracts/src/providerRuntime.ts | 5 + 6 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpElicitation.test.ts diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..0d52eeedef0 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1301,6 +1301,11 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); if (!answer) { + // Optional questions can be left unanswered; only required questions + // block submission. Unanswered optional questions are simply omitted. + if (question.optional) { + continue; + } return null; } answers[question.id] = answer; diff --git a/apps/server/src/provider/acp/AcpElicitation.test.ts b/apps/server/src/provider/acp/AcpElicitation.test.ts new file mode 100644 index 00000000000..a04ac6229c7 --- /dev/null +++ b/apps/server/src/provider/acp/AcpElicitation.test.ts @@ -0,0 +1,138 @@ +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + extractElicitationQuestions, + makeElicitationAcceptedResponse, + makeElicitationDeclinedResponse, +} from "./AcpElicitation.ts"; + +function formRequest( + requestedSchema: Record, + message = "Configure the run", +): EffectAcpSchema.ElicitationRequest { + return { + mode: "form", + message, + sessionId: "session-1", + requestedSchema, + } as unknown as EffectAcpSchema.ElicitationRequest; +} + +describe("AcpElicitation", () => { + describe("extractElicitationQuestions", () => { + it("marks properties absent from `required` as optional", () => { + const questions = extractElicitationQuestions( + formRequest({ + type: "object", + properties: { + color: { type: "string", description: "Pick a color", enum: ["red", "blue"] }, + notes: { type: "string", description: "Optional notes" }, + }, + required: ["color"], + }), + ); + + const color = questions.find((question) => question.id === "color"); + const notes = questions.find((question) => question.id === "notes"); + expect(color?.optional).toBe(false); + expect(notes?.optional).toBe(true); + // Enum options are surfaced as selectable options. + expect(color?.options.map((option) => option.label)).toEqual(["red", "blue"]); + }); + + it("treats all properties as optional when `required` is absent", () => { + const questions = extractElicitationQuestions( + formRequest({ + type: "object", + properties: { notes: { type: "string" } }, + }), + ); + expect(questions.every((question) => question.optional === true)).toBe(true); + }); + + it("emits a single required confirm question when there are no properties", () => { + const questions = extractElicitationQuestions( + formRequest({ type: "object", properties: {} }), + ); + expect(questions).toHaveLength(1); + expect(questions[0]?.id).toBe("confirm"); + expect(questions[0]?.optional).not.toBe(true); + }); + + it("returns no questions for url-mode elicitations", () => { + const request = { + mode: "url", + message: "Open this", + sessionId: "session-1", + elicitationId: "e1", + url: "https://example.com", + } as unknown as EffectAcpSchema.ElicitationRequest; + expect(extractElicitationQuestions(request)).toEqual([]); + }); + }); + + describe("makeElicitationAcceptedResponse", () => { + const request = formRequest({ + type: "object", + properties: { + agree: { type: "boolean", title: "Proceed?" }, + count: { type: "integer", description: "How many" }, + ratio: { type: "number", description: "Ratio" }, + mode: { + type: "string", + oneOf: [ + { const: "fast", title: "Fast mode" }, + { const: "slow", title: "Slow mode" }, + ], + }, + notes: { type: "string" }, + }, + required: ["agree"], + }); + + it("coerces recognized boolean answers", () => { + expect(makeElicitationAcceptedResponse(request, { agree: "Yes" }).action).toMatchObject({ + action: "accept", + content: { agree: true }, + }); + expect(makeElicitationAcceptedResponse(request, { agree: "No" }).action).toMatchObject({ + content: { agree: false }, + }); + expect(makeElicitationAcceptedResponse(request, { agree: "true" }).action).toMatchObject({ + content: { agree: true }, + }); + }); + + it("omits unrecognized boolean answers instead of defaulting to false", () => { + const response = makeElicitationAcceptedResponse(request, { agree: "maybe" }); + const content = + response.action.action === "accept" ? (response.action.content ?? {}) : undefined; + expect(content).toBeDefined(); + expect(content && "agree" in content).toBe(false); + }); + + it("coerces numeric and oneOf answers and maps titles back to const values", () => { + const response = makeElicitationAcceptedResponse(request, { + count: "42", + ratio: "1.5", + mode: "Fast mode", + }); + expect(response.action).toMatchObject({ + action: "accept", + content: { count: 42, ratio: 1.5, mode: "fast" }, + }); + }); + + it("omits keys without an answer", () => { + const response = makeElicitationAcceptedResponse(request, { agree: "Yes" }); + const content = + response.action.action === "accept" ? (response.action.content ?? {}) : undefined; + expect(content).toEqual({ agree: true }); + }); + }); + + it("declines with a decline action", () => { + expect(makeElicitationDeclinedResponse().action).toEqual({ action: "decline" }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpElicitation.ts b/apps/server/src/provider/acp/AcpElicitation.ts index 9e21e1470b1..c0107b19fc9 100644 --- a/apps/server/src/provider/acp/AcpElicitation.ts +++ b/apps/server/src/provider/acp/AcpElicitation.ts @@ -40,15 +40,17 @@ function truncateHeader(value: string): string { function formRequestedSchema(request: EffectAcpSchema.ElicitationRequest): { properties: Record; + required: ReadonlyArray; title?: string; description?: string; } { if (request.mode !== "form") { - return { properties: {} }; + return { properties: {}, required: [] }; } const schema = request.requestedSchema; return { properties: (schema.properties ?? {}) as Record, + required: schema.required ?? [], ...(trimmed(schema.title) ? { title: trimmed(schema.title)! } : {}), ...(trimmed(schema.description) ? { description: trimmed(schema.description)! } : {}), }; @@ -104,6 +106,9 @@ export function extractElicitationQuestions( }, ]; } + // Fields not listed in `required` must not block submission, otherwise the + // user is forced to invent values for optional properties to accept the form. + const requiredKeys = new Set(schema.required); return keys.map((key) => { const property = schema.properties[key]!; return { @@ -112,6 +117,7 @@ export function extractElicitationQuestions( question: trimmed(property.description) ?? trimmed(property.title) ?? key, options: optionsForProperty(property), multiSelect: false, + optional: !requiredKeys.has(key), } satisfies UserInputQuestion; }); } @@ -144,7 +150,17 @@ function coerceAnswer( return undefined; } if (property.type === "boolean") { - return value === "Yes" || value.toLowerCase() === "true"; + // Only accept recognized boolean representations. Because the UI allows + // free-text answers, unrecognized input must not silently become `false` — + // return undefined so the key is omitted from the response instead. + const normalized = value.toLowerCase(); + if (normalized === "yes" || normalized === "true") { + return true; + } + if (normalized === "no" || normalized === "false") { + return false; + } + return undefined; } if (property.type === "number" || property.type === "integer") { const parsed = Number(value); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 3d1cb336e1f..458d2e7b96e 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -248,3 +248,51 @@ describe("pending user input question progress", () => { }); }); }); + +describe("optional questions", () => { + const requiredQuestion = { + id: "required", + header: "Required", + question: "Pick a value", + options: [{ label: "A", description: "A" }], + multiSelect: false, + } as const; + const optionalQuestion = { + id: "optional", + header: "Optional", + question: "Extra notes", + options: [], + multiSelect: false, + optional: true, + } as const; + + it("does not block submission when only optional questions are unanswered", () => { + const answers = buildPendingUserInputAnswers([requiredQuestion, optionalQuestion], { + required: { selectedOptionLabels: ["A"] }, + }); + // Required answered, optional omitted -> submission allowed, optional key absent. + expect(answers).toEqual({ required: "A" }); + }); + + it("still blocks submission when a required question is unanswered", () => { + expect( + buildPendingUserInputAnswers([requiredQuestion, optionalQuestion], { + optional: { customAnswer: "just notes" }, + }), + ).toBeNull(); + }); + + it("includes optional answers when they are provided", () => { + expect( + buildPendingUserInputAnswers([requiredQuestion, optionalQuestion], { + required: { selectedOptionLabels: ["A"] }, + optional: { customAnswer: "extra" }, + }), + ).toEqual({ required: "A", optional: "extra" }); + }); + + it("allows advancing past an unanswered optional question", () => { + const progress = derivePendingUserInputProgress([optionalQuestion, requiredQuestion], {}, 0); + expect(progress.canAdvance).toBe(true); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index d3a7a129378..f6b24f5eb9b 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -111,6 +111,11 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { + // Optional questions can be left unanswered; only required questions + // block submission. Unanswered optional questions are simply omitted. + if (question.optional) { + continue; + } return null; } answers[question.id] = answer; @@ -167,6 +172,6 @@ export function derivePendingUserInputProgress( answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, - canAdvance: Boolean(resolvedAnswer), + canAdvance: Boolean(resolvedAnswer) || activeQuestion?.optional === true, }; } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..9117a4a9983 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -446,6 +446,11 @@ export const UserInputQuestion = Schema.Struct({ multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), ), + // When true, the submission flow may be completed without answering this + // question. Defaults to false so existing (required) questions are unchanged. + optional: Schema.optional(Schema.Boolean).pipe( + Schema.withConstructorDefault(Effect.succeed(false)), + ), }); export type UserInputQuestion = typeof UserInputQuestion.Type; From 983df764dc746692655d7092bf0dac62104e17b1 Mon Sep 17 00:00:00 2001 From: Adithya Sakaray Date: Sun, 19 Jul 2026 13:34:55 +0530 Subject: [PATCH 5/5] feat(acp): add idle turn watchdog and empty-completion notice - Idle turn watchdog (shared by Kiro/Grok/Cursor): when a prompt is in flight but the agent produces no ACP activity for longer than T3CODE_ACP_TURN_IDLE_TIMEOUT_MS (default 240s, 0 disables), surface a runtime error and cancel the turn instead of spinning forever. Fixes the Kiro hang where a shell tool never returned a result. - Empty-completion notice: when a turn completes with no assistant content and no tool activity (e.g. Grok ending the turn after a 402 usage-limit inference failure), emit a runtime.warning so the user sees why instead of a silent stop. - Track per-turn output + last activity on the session context. - Tests + a T3_ACP_EMIT_EMPTY_COMPLETION mock-agent flag. --- apps/server/scripts/acp-mock-agent.ts | 8 + .../src/provider/Layers/GrokAdapter.test.ts | 113 ++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 140 ++++++++++++++++++ 3 files changed, 261 insertions(+) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index ccf6f063264..822c0c4be8d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -23,6 +23,7 @@ const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLET const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; +const emitEmptyCompletion = process.env.T3_ACP_EMIT_EMPTY_COMPLETION === "1"; const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; @@ -466,6 +467,13 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + if (emitEmptyCompletion) { + // Complete the turn immediately with no assistant content and no tool + // activity — mirrors a provider that silently gave up (e.g. Grok ending + // the turn after a usage-limit inference failure). + return { stopReason: "end_turn" }; + } + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { return { stopReason: "end_turn", diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..aa755a5a62a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,4 +1197,117 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect( + "surfaces a runtime error and cancels the turn when the agent goes silent mid-turn", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_HANG_PROMPT_FOREVER: "1" }), + ); + const previousTimeout = process.env.T3CODE_ACP_TURN_IDLE_TIMEOUT_MS; + process.env.T3CODE_ACP_TURN_IDLE_TIMEOUT_MS = "400"; + const adapter = yield* makeTestAdapter(wrapperPath).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousTimeout === undefined) { + delete process.env.T3CODE_ACP_TURN_IDLE_TIMEOUT_MS; + } else { + process.env.T3CODE_ACP_TURN_IDLE_TIMEOUT_MS = previousTimeout; + } + }), + ), + ); + + const runtimeErrorSeen = yield* Deferred.make(); + const turnStarted = yield* Deferred.make(); + const cancelledSeen = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (event.type === "turn.started") { + yield* Deferred.succeed(turnStarted, undefined); + } + if (event.type === "runtime.error") { + yield* Deferred.succeed(runtimeErrorSeen, event); + } + if (event.type === "turn.completed" && event.payload.state === "cancelled") { + yield* Deferred.succeed(cancelledSeen, undefined); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + // The prompt hangs forever; run it in the background so the watchdog can + // fire while it is still in flight. + yield* adapter + .sendTurn({ threadId, input: "please hang", attachments: [] }) + .pipe(Effect.forkChild); + + // Wait until the prompt is actually in flight (turn.started emitted), + // then advance virtual time past the idle timeout to trip the watchdog. + yield* Deferred.await(turnStarted); + yield* TestClock.adjust("2 seconds"); + + const errorEvent = yield* Deferred.await(runtimeErrorSeen); + assert.strictEqual(errorEvent.type, "runtime.error"); + if (errorEvent.type === "runtime.error") { + assert.include(errorEvent.payload.message, "stopped responding"); + } + // The watchdog also cancels the wedged turn so the UI stops spinning. + yield* Deferred.await(cancelledSeen); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces a warning when the agent completes a turn with no output", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-empty-completion-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_EMPTY_COMPLETION: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "check the repo", attachments: [] }); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("15 seconds")); + + const warning = runtimeEvents.find((event) => event.type === "runtime.warning"); + assert.isDefined(warning); + if (warning?.type === "runtime.warning") { + assert.include(warning.payload.message, "without a response"); + } + // No assistant content was produced for the empty turn. + assert.isUndefined(runtimeEvents.find((event) => event.type === "content.delta")); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c45a51fbf42..f72fda55d6f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -12,9 +12,11 @@ import { type ThreadId, TurnId, } from "@t3tools/contracts"; +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"; @@ -79,6 +81,26 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJ const GROK_PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; +/** + * Default idle timeout for the turn watchdog. If the agent produces no ACP + * activity for this long while a prompt is in flight, the turn is treated as + * hung: a runtime error is surfaced and the turn is cancelled instead of the UI + * spinning forever. Generous by default so genuinely long-running tools are not + * killed; override with `T3CODE_ACP_TURN_IDLE_TIMEOUT_MS` (0 disables). + */ +const DEFAULT_TURN_IDLE_TIMEOUT_MS = 240_000; +/** How often the watchdog checks for idleness. */ +const TURN_IDLE_WATCHDOG_INTERVAL_MS = 15_000; + +function resolveTurnIdleTimeoutMs(): number { + const raw = process.env.T3CODE_ACP_TURN_IDLE_TIMEOUT_MS?.trim(); + if (!raw) { + return DEFAULT_TURN_IDLE_TIMEOUT_MS; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_TURN_IDLE_TIMEOUT_MS; +} + function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); return Exit.isSuccess(result) ? result.value : undefined; @@ -131,6 +153,13 @@ interface GrokSessionContext { promptsInFlight: number; currentModelId: string | undefined; stopped: boolean; + /** Epoch millis of the last ACP activity (turn start or inbound update). + * Used by the idle watchdog to detect an agent that went silent mid-turn. */ + lastActivityAtMillis: number; + /** Whether the active turn has produced any assistant/tool output yet. + * A turn that completes without output (e.g. provider hit a usage limit and + * ended silently) triggers an empty-completion notice. */ + activeTurnProducedOutput: boolean; } function settlePendingApprovalsAsCancelled( @@ -257,6 +286,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const turnIdleTimeoutMs = resolveTurnIdleTimeoutMs(); const sessions = new Map(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); @@ -311,6 +341,35 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const withThreadLock = (threadId: string, effect: Effect.Effect) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + // Surfaces a visible notice when a turn ends "successfully" but produced no + // assistant text and no tool activity. This is the observable shape of an + // agent that silently gave up mid-turn — most notably Grok ending the turn + // with `end_turn` after its inference failed on a usage/credit limit (HTTP + // 402), which otherwise leaves the user with no message and no error. + const emitEmptyCompletionNoticeIfNeeded = ( + ctx: GrokSessionContext, + turnId: TurnId, + stopReason: EffectAcpSchema.StopReason | null, + ) => + Effect.gen(function* () { + if (ctx.activeTurnProducedOutput || stopReason === "cancelled") { + return; + } + yield* offerRuntimeEvent({ + type: "runtime.warning", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { + message: `${providerDisplayName} ended the turn without a response.`, + detail: `The model produced no output before ending the turn${ + stopReason ? ` (stop reason: ${stopReason})` : "" + }. This commonly happens when a usage or credit limit has been reached, or the request was declined.`, + }, + }); + }); + const settlePromptInFlight = ( threadId: ThreadId, turnId: TurnId, @@ -435,6 +494,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }, }); } else if (shouldEmitCompletedTurn) { + yield* emitEmptyCompletionNoticeIfNeeded( + liveCtx, + settleTurnId, + options.completedStopReason ?? null, + ); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -850,6 +914,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte promptsInFlight: 0, currentModelId: boundModelId, stopped: false, + lastActivityAtMillis: yield* Clock.currentTimeMillis, + activeTurnProducedOutput: false, }; const nf = yield* Stream.runDrain( @@ -878,6 +944,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ) { return; } + // Any inbound update for the active turn counts as liveness for + // the idle watchdog, and marks that the turn produced output so a + // silent (e.g. usage-limited) completion can be distinguished. + ctx.lastActivityAtMillis = yield* Clock.currentTimeMillis; + if ( + event._tag === "AssistantItemStarted" || + event._tag === "ContentDelta" || + event._tag === "ToolCallUpdated" || + event._tag === "PlanUpdated" + ) { + ctx.activeTurnProducedOutput = true; + } const stamp = yield* makeEventStamp(); switch (event._tag) { @@ -956,6 +1034,63 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte sessions.set(input.threadId, ctx); sessionScopeTransferred = true; + // Idle turn watchdog: if a prompt is in flight but the agent produces + // no ACP activity for longer than the timeout, treat the turn as hung + // (e.g. the agent wedged on a tool/command that never returns), surface + // a runtime error, and cancel it instead of spinning forever. + yield* Effect.gen(function* () { + if (turnIdleTimeoutMs <= 0) { + return; + } + // Poll at most every TURN_IDLE_WATCHDOG_INTERVAL_MS, but never slower + // than the timeout itself, so a short (e.g. test) timeout is honored + // promptly without busy-looping on the default multi-minute timeout. + const checkIntervalMs = Math.min( + TURN_IDLE_WATCHDOG_INTERVAL_MS, + Math.max(250, turnIdleTimeoutMs), + ); + while (true) { + yield* Effect.sleep(Duration.millis(checkIntervalMs)); + const liveCtx = sessions.get(input.threadId); + if (!liveCtx || liveCtx.stopped) { + return; + } + const activeTurnId = liveCtx.activeTurnId; + if ( + liveCtx.promptsInFlight <= 0 || + activeTurnId === undefined || + liveCtx.interruptedTurnIds.has(activeTurnId) + ) { + continue; + } + const nowMillis = yield* Clock.currentTimeMillis; + const idleMillis = nowMillis - liveCtx.lastActivityAtMillis; + if (idleMillis < turnIdleTimeoutMs) { + continue; + } + const idleSeconds = Math.round(idleMillis / 1000); + yield* offerRuntimeEvent({ + type: "runtime.error", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: activeTurnId, + payload: { + message: `${providerDisplayName} stopped responding and the turn was cancelled.`, + detail: `No activity for ${idleSeconds}s while a response was in progress. The agent may have hung on a tool or command that never returned. Retry, or raise T3CODE_ACP_TURN_IDLE_TIMEOUT_MS if long-running work is expected.`, + }, + }); + yield* interruptTurn(input.threadId, activeTurnId).pipe(Effect.ignore); + } + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning(`${providerDisplayName} turn watchdog stopped unexpectedly.`, { + cause, + }), + ), + Effect.forkIn(sessionScope), + ); + yield* offerRuntimeEvent({ type: "session.started", ...(yield* makeEventStamp()), @@ -1003,6 +1138,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + // Reset the idle-watchdog clock: a freshly sent prompt is activity, + // and gives the agent a full timeout window before its first token. + ctx.lastActivityAtMillis = yield* Clock.currentTimeMillis; ctx.session = { ...ctx.session, status: steeringTurnId === undefined ? "connecting" : "running", @@ -1092,6 +1230,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } if (steeringTurnId === undefined) { ctx.lastPlanFingerprint = undefined; + ctx.activeTurnProducedOutput = false; } ctx.session = { ...ctx.session, @@ -1253,6 +1392,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* emitEmptyCompletionNoticeIfNeeded(ctx, prepared.turnId, completedStopReason); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()),