1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import {
5 MessageId,
6 type ModelSelection,
7 NodeId,
8 type ProviderApprovalDecision,
9 ProjectId,
10 ProviderInstanceId,
11 ProviderSessionId,
12 type ProviderTurnId,
13 RunAttemptId,
14 RunId,
15 type RuntimeMode,
16 type RuntimeRequestId,
17 ThreadId,
18} from "@t3tools/contracts";
19import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
20import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
21import * as Cause from "effect/Cause";
22import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";
24import * as Effect from "effect/Effect";
25import * as Exit from "effect/Exit";
26import * as Fiber from "effect/Fiber";
27import * as FileSystem from "effect/FileSystem";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Path from "effect/Path";
31import * as Schema from "effect/Schema";
32import * as Stream from "effect/Stream";
33import * as TestClock from "effect/testing/TestClock";
34import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
35import type * as EffectAcpSchema from "effect-acp/compat";
36
37import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
38import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
39import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
40import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
41import {
42 ProviderAdapterTurnStartError,
43 type ProviderAdapterV2Error,
44 type ProviderAdapterV2Event,
45 type ProviderAdapterV2TurnInput,
46 ProviderCompactionUnsupportedError,
47 ProviderAdapterV2RuntimePolicy,
48} from "@t3tools/provider-core/server/ProviderAdapter";
49import {
50 decodeAcpReplayTranscript,
51 makeAcpReplayCompletenessAssertion,
52 makeAcpReplayRuntime,
53} from "./AcpAdapterV2.testkit.ts";
54import {
55 makeProviderReplayGate,
56 type ProviderReplayGate,
57} from "@t3tools/provider-testing/replayGate";
58import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript";
59import { KiroSettings } from "@t3tools/provider-kiro/settings";
60import {
61 KIRO_PROVIDER,
62 kiroApprovalOptions,
63 kiroAutopilotValue,
64 kiroPermissionDisposition,
65 makeKiroAdapterV2,
66} from "@t3tools/provider-kiro/testing";
67
68const testLayer = Layer.mergeAll(
69 NodeServices.layer,
70 NodeCrypto.layer,
71 IdAllocator.layer,
72 McpProviderSessions.layer,
73 TestProviderHost.layer().pipe(
74 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
75 ),
76);
77
78const SESSION_ID = "sess_abb5e0cf-d4a2-4360-9f03-2f8f0889a707";
79const ENABLED_KIRO_SETTINGS = Schema.decodeSync(KiroSettings)({ enabled: true });
80
81type Frame = Record<string, unknown>;
82const outbound = (method: string, params: unknown = "<any>", label = method): Frame => ({
83 type: "expect_outbound",
84 label,
85 frame: { kind: "request", method, params },
86});
87const answer = (method: string, result: unknown, label = `${method}.result`): Frame => ({
88 type: "emit_inbound",
89 label,
90 frame: { kind: "response", method, result },
91});
92const update = (sessionUpdate: Record<string, unknown>): Frame => ({
93 type: "emit_inbound",
94 label: `update.${String(sessionUpdate.sessionUpdate)}`,
95 frame: {
96 kind: "notification",
97 method: "session/update",
98 params: { sessionId: SESSION_ID, update: sessionUpdate },
99 },
100});
101const kiroNotification = (method: string, params: unknown): Frame => ({
102 type: "emit_inbound",
103 label: method,
104 frame: { kind: "notification", method, params },
105});
106
107/**
108 * Kiro CLI 2.27.0 (KAS 0.66.22) `initialize` result, recorded with
109 * `kiro-cli acp --agent-engine=v3 --auth-method=cli`. Only the extension
110 * method list is shortened and the log paths dropped.
111 */
112const KIRO_V3_INITIALIZE = {
113 protocolVersion: 1,
114 agentCapabilities: {
115 loadSession: true,
116 promptCapabilities: { image: true, embeddedContext: true },
117 mcpCapabilities: { http: true, sse: true },
118 sessionCapabilities: {
119 list: {},
120 close: {},
121 delete: {},
122 fork: { _meta: { kiro: { messageId: true } } },
123 },
124 _meta: {
125 kiro: {
126 checkpoints: true,
127 sessionList: true,
128 policyNotifications: true,
129 extensionMethods: ["_kiro/session/context", "_kiro/session/compact", "_kiro/knowledge"],
130 sessionSources: ["local", "remote"],
131 sessionListScopes: ["workspace", "user"],
132 executionTargets: ["local", "cloud-sandbox"],
133 replayMarking: true,
134 },
135 },
136 },
137 authMethods: [
138 { id: "aws-builder-id", name: "AWS Builder ID" },
139 { id: "aws-iam-identity-center", name: "AWS IAM Identity Center" },
140 ],
141};
142
143/** A deployment that advertises no Kiro extensions at all. */
144const KIRO_V3_INITIALIZE_WITHOUT_EXTENSIONS = {
145 protocolVersion: 1,
146 agentCapabilities: {
147 loadSession: true,
148 promptCapabilities: { image: true, embeddedContext: true },
149 mcpCapabilities: { http: true, sse: true },
150 },
151 authMethods: [],
152};
153
154const option = (value: string, name = value) => ({ value, name });
155const autopilotOption = (currentValue: "on" | "off") => ({
156 type: "select",
157 id: "autopilot",
158 name: "Autopilot",
159 currentValue,
160 options: [option("on", "Autopilot"), option("off", "Supervised")],
161});
162const modeOption = {
163 type: "select",
164 id: "mode",
165 name: "Mode",
166 category: "mode",
167 currentValue: "vibe",
168 options: [option("vibe", "Default"), option("spec", "Spec"), option("plan", "Plan")],
169};
170// Recorded sessions without a signed-in account carry no `model` option; the
171// migration guide documents it as `configId: "model"`.
172const modelOption = (currentValue: string) => ({
173 type: "select",
174 id: "model",
175 name: "Model",
176 category: "model",
177 currentValue,
178 options: [option("auto", "Auto"), option("claude-sonnet", "Claude Sonnet")],
179});
180
181/**
182 * Kiro's `model` option as 2.27.0 advertises it (fixtures/kiro_model_switch),
183 * trimmed to three choices. Each choice's `_meta.kiro` names its effort levels.
184 */
185const kiroModelChoice = (
186 value: string,
187 name: string,
188 effort?: { readonly levels: ReadonlyArray<string>; readonly defaultLevel: string },
189) => ({
190 value,
191 name,
192 _meta: {
193 kiro:
194 effort === undefined
195 ? { hasEffort: false, thinkingToggleable: false }
196 : {
197 hasEffort: true,
198 effortSchemaPath: "output_config",
199 effortLevels: effort.levels,
200 defaultEffortLevel: effort.defaultLevel,
201 thinkingToggleable: false,
202 },
203 },
204});
205const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
206const CLAUDE_4_6_EFFORT_LEVELS = ["low", "medium", "high", "max"];
207const kiroModelOption = (currentValue: string) => ({
208 type: "select",
209 id: "model",
210 name: "Model",
211 category: "model",
212 currentValue,
213 options: [
214 kiroModelChoice("auto", "Auto"),
215 kiroModelChoice("claude-opus-5.5", "Claude Opus 5.5", {
216 levels: CLAUDE_EFFORT_LEVELS,
217 defaultLevel: "medium",
218 }),
219 kiroModelChoice("claude-opus-4.6", "Claude Opus 4.6", {
220 levels: CLAUDE_4_6_EFFORT_LEVELS,
221 defaultLevel: "high",
222 }),
223 kiroModelChoice("claude-sonnet-4.5", "Claude Sonnet 4.5"),
224 ],
225});
226/**
227 * Kiro's effort option as kiro-cli 2.27.0 advertised it to a direct ACP probe
228 * on Claude Opus 5.5: a `thought_level` select with id `effortLevel`, present
229 * only while a model with effort runs and starting at that model's default
230 * level. No replay fixture records it yet.
231 */
232const effortOption = (currentValue: string, levels: ReadonlyArray<string>) => ({
233 type: "select",
234 id: "effortLevel",
235 name: "Effort",
236 category: "thought_level",
237 currentValue,
238 options: levels.map((level) => option(level)),
239});
240const setConfig = (configId: string, value: string) =>
241 outbound("session/set_config_option", { sessionId: SESSION_ID, configId, value });
242const configResult = (configOptions: ReadonlyArray<unknown>) =>
243 answer("session/set_config_option", { configOptions });
244const reasoning = (value: string) => [{ id: "reasoningEffort", value }];
245
246/** Kiro's `session/new` result, recorded from 2.27.0 and trimmed to what T3 reads. */
247const sessionSetup = (configOptions: ReadonlyArray<unknown>) => ({
248 _meta: { schemaVersion: "1.0.0", id: SESSION_ID, agentMode: "vibe", source: "local" },
249 sessionId: SESSION_ID,
250 modes: {
251 currentModeId: "vibe",
252 availableModes: [
253 { id: "vibe", name: "Default" },
254 { id: "spec", name: "Spec" },
255 { id: "plan", name: "Plan" },
256 ],
257 },
258 configOptions,
259});
260
261/** What Kiro sends right after `initialize` and around `session/new`, all unknown to T3. */
262const kiroSessionNoise: ReadonlyArray<Frame> = [
263 kiroNotification("_kiro/mcp/status", { sessionId: SESSION_ID, servers: [] }),
264 kiroNotification("_kiro/sessions/changed", { upserted: [], deleted: [] }),
265];
266
267// Kiro advertises `sessionCapabilities.close`, so T3 closes the session on teardown.
268const closeSession: ReadonlyArray<Frame> = [
269 outbound("session/close", { sessionId: SESSION_ID }),
270 answer("session/close", {}),
271];
272
273const turnPrompt = outbound("session/prompt", {
274 sessionId: SESSION_ID,
275 prompt: "<any>",
276});
277
278const openSessionFrames = (input: {
279 readonly initialize: unknown;
280 readonly configOptions: ReadonlyArray<unknown>;
281}): ReadonlyArray<Frame> => [
282 outbound("initialize"),
283 answer("initialize", input.initialize),
284 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
285 ...kiroSessionNoise,
286 answer("session/new", sessionSetup(input.configOptions)),
287 // Supervised threads run Kiro with Autopilot off, its native ask-first posture.
288 outbound("session/set_config_option", {
289 sessionId: SESSION_ID,
290 configId: "autopilot",
291 value: "off",
292 }),
293 answer("session/set_config_option", {
294 configOptions: input.configOptions.map((entry) =>
295 (entry as { id: string }).id === "autopilot" ? autopilotOption("off") : entry,
296 ),
297 }),
298];
299
300const runKiroScript = Effect.fn("runKiroScript")(function* (input: {
301 readonly scenario: string;
302 readonly frames: ReadonlyArray<Frame>;
303 readonly model?: string;
304 /** Option selections (e.g. reasoningEffort) for the session and `startTurn`. */
305 readonly options?: ModelSelection["options"];
306 readonly runtimeMode?: RuntimeMode;
307 /** Holds the script's inbound frames at labels the test releases. */
308 readonly replayGate?: ProviderReplayGate;
309 readonly testHooks?: Parameters<typeof makeKiroAdapterV2>[0]["testHooks"];
310 /** Wraps the runtime's `session/cancel` send. */
311 readonly wrapCancel?: (
312 send: AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"],
313 ) => AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"];
314 readonly drive: (session: {
315 readonly events: Stream.Stream<ProviderAdapterV2Event, ProviderAdapterV2Error>;
316 readonly startTurn: Effect.Effect<void, ProviderAdapterV2Error>;
317 /** Starts turn `ordinal` (from 1) on `model`, with `options` if given. */
318 readonly startTurnOn: (
319 ordinal: number,
320 model: string,
321 options?: ModelSelection["options"],
322 ) => Effect.Effect<void, ProviderAdapterV2Error>;
323 /** Runs T3's compact action as turn `ordinal`. */
324 readonly compact: (ordinal: number) => Effect.Effect<void, ProviderAdapterV2Error>;
325 readonly interrupt: (
326 providerTurnId: ProviderTurnId,
327 ) => Effect.Effect<void, ProviderAdapterV2Error>;
328 readonly respond: (
329 requestId: RuntimeRequestId,
330 decision: ProviderApprovalDecision,
331 ) => Effect.Effect<void, ProviderAdapterV2Error>;
332 }) => Effect.Effect<void, ProviderAdapterV2Error>;
333}) {
334 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
335 const fileSystem = yield* FileSystem.FileSystem;
336 const path = yield* Path.Path;
337 const replayDir = yield* fileSystem.makeTempDirectoryScoped({
338 prefix: `t3-kiro-${input.scenario}-`,
339 });
340 const statusPath = path.join(replayDir, "status.json");
341 const transcript = yield* decodeAcpReplayTranscript(
342 {
343 provider: KIRO_PROVIDER,
344 protocol: "acp.ndjson-jsonrpc",
345 version: "1",
346 scenario: input.scenario,
347 entries: input.frames as never,
348 },
349 KIRO_PROVIDER,
350 );
351 const instanceId = ProviderInstanceId.make(`kiro-${input.scenario}`);
352 const scriptPath = yield* path.fromFileUrl(
353 new URL("../../../scripts/acp-replay-agent.ts", import.meta.url),
354 );
355 const adapter = yield* makeKiroAdapterV2({
356 instanceId,
357 settings: ENABLED_KIRO_SETTINGS,
358 environment: {},
359 selfInvocation: yield* resolveSelfInvocation(),
360 makeRuntime: (runtimeInput) =>
361 makeAcpReplayRuntime({
362 transcript,
363 statusPath,
364 scriptPath,
365 childProcessSpawner,
366 fileSystem,
367 ...(input.replayGate === undefined ? {} : { replayGate: input.replayGate }),
368 })(runtimeInput).pipe(
369 Effect.map((runtime) =>
370 input.wrapCancel === undefined
371 ? runtime
372 : { ...runtime, cancel: input.wrapCancel(runtime.cancel) },
373 ),
374 ),
375 ...(input.testHooks === undefined ? {} : { testHooks: input.testHooks }),
376 });
377 // Held frames must not outlive a failed drive and wedge teardown.
378 yield* Effect.addFinalizer(() => Effect.sync(() => input.replayGate?.releaseAll()));
379 const threadId = ThreadId.make(`thread-kiro-${input.scenario}`);
380 const modelSelection = {
381 instanceId,
382 model: input.model ?? "default",
383 ...(input.options === undefined ? {} : { options: input.options }),
384 };
385 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
386 runtimeMode: input.runtimeMode ?? "approval-required",
387 interactionMode: "default",
388 cwd: replayDir,
389 });
390 yield* Effect.gen(function* () {
391 const session = yield* adapter.openSession({
392 threadId,
393 providerSessionId: ProviderSessionId.make(`provider-session-kiro-${input.scenario}`),
394 modelSelection,
395 runtimePolicy,
396 });
397 const providerThread = yield* session.ensureThread({ threadId, modelSelection, runtimePolicy });
398 const now = yield* DateTime.now;
399 const turnInput = (
400 ordinal: number,
401 model: string,
402 options?: ModelSelection["options"],
403 text = "Say hello",
404 ): ProviderAdapterV2TurnInput => {
405 const suffix = `${threadId}:${ordinal}`;
406 const turnSelection = { instanceId, model, ...(options === undefined ? {} : { options }) };
407 return {
408 appThread: {
409 createdBy: "user",
410 creationSource: "web",
411 id: threadId,
412 projectId: ProjectId.make(`project:${threadId}`),
413 title: "Kiro adapter test",
414 providerInstanceId: instanceId,
415 modelSelection: turnSelection,
416 runtimeMode: runtimePolicy.runtimeMode,
417 interactionMode: "default",
418 branch: null,
419 worktreePath: null,
420 activeProviderThreadId: providerThread.id,
421 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
422 forkedFrom: null,
423 createdAt: now,
424 updatedAt: now,
425 archivedAt: null,
426 settledOverride: null,
427 settledAt: null,
428 lastVisitedAt: null,
429 deletedAt: null,
430 },
431 threadId,
432 runId: RunId.make(`run:${suffix}`),
433 runOrdinal: ordinal,
434 providerTurnOrdinal: ordinal,
435 attemptId: RunAttemptId.make(`attempt:${suffix}`),
436 rootNodeId: NodeId.make(`node:${suffix}`),
437 providerThread,
438 message: {
439 createdBy: "user",
440 creationSource: "web",
441 messageId: MessageId.make(`message:${suffix}`),
442 text,
443 attachments: [],
444 },
445 modelSelection: turnSelection,
446 runtimePolicy,
447 };
448 };
449 const startTurnOn = (ordinal: number, model: string, options?: ModelSelection["options"]) =>
450 session.startTurn(turnInput(ordinal, model, options));
451 const compact = (ordinal: number) =>
452 session.compactThread === undefined
453 ? Effect.die("Kiro adapter does not offer compactThread")
454 : session.compactThread(
455 turnInput(ordinal, modelSelection.model, input.options, "/compact"),
456 );
457 yield* input.drive({
458 events: session.events,
459 startTurn: startTurnOn(1, modelSelection.model, input.options),
460 startTurnOn,
461 compact,
462 interrupt: (providerTurnId) => session.interruptTurn({ providerThread, providerTurnId }),
463 respond: (requestId, decision) => session.respondToRuntimeRequest({ requestId, decision }),
464 });
465 }).pipe(Effect.scoped);
466 // Closing the session stops the replay agent; every scripted frame must be used.
467 yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript);
468});
469
470const collectTurn = (events: Stream.Stream<ProviderAdapterV2Event, ProviderAdapterV2Error>) =>
471 events.pipe(
472 Stream.takeUntil((event) => event.type === "turn.terminal"),
473 Stream.runCollect,
474 Effect.map((collected) => Array.from(collected)),
475 );
476
477const terminalStatus = (events: ReadonlyArray<ProviderAdapterV2Event>) => {
478 const terminal = events.find((event) => event.type === "turn.terminal");
479 return terminal?.type === "turn.terminal" ? terminal.status : undefined;
480};
481
482describe("KiroAdapterV2", () => {
483 it.effect("opens a session and refuses compaction on a deployment with no Kiro extensions", () =>
484 runKiroScript({
485 scenario: "no-extensions",
486 frames: [
487 outbound("initialize"),
488 answer("initialize", KIRO_V3_INITIALIZE_WITHOUT_EXTENSIONS),
489 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
490 // No autopilot option advertised, so T3 sends no config write.
491 answer("session/new", sessionSetup([modeOption])),
492 turnPrompt,
493 update({
494 sessionUpdate: "agent_message_chunk",
495 content: { type: "text", text: "ok" },
496 }),
497 answer("session/prompt", { stopReason: "end_turn" }),
498 ],
499 drive: ({ events, startTurn, compact }) =>
500 Effect.gen(function* () {
501 yield* startTurn;
502 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
503 // `_kiro/session/compact` is not advertised, so T3 must not send it.
504 const compacted = yield* Effect.exit(compact(2));
505 const error = Exit.isFailure(compacted) ? Cause.squash(compacted.cause) : undefined;
506 assert.instanceOf(error, ProviderAdapterTurnStartError);
507 assert.instanceOf(
508 (error as ProviderAdapterTurnStartError).cause,
509 ProviderCompactionUnsupportedError,
510 );
511 }),
512 }).pipe(Effect.provide(testLayer), Effect.scoped),
513 );
514
515 it.effect("switches back to Kiro's default model after a named one", () =>
516 runKiroScript({
517 scenario: "default-after-named",
518 frames: [
519 ...openSessionFrames({
520 initialize: KIRO_V3_INITIALIZE,
521 configOptions: [modeOption, modelOption("auto"), autopilotOption("on")],
522 }),
523 turnPrompt,
524 answer("session/prompt", { stopReason: "end_turn" }),
525 outbound("session/set_config_option", {
526 sessionId: SESSION_ID,
527 configId: "model",
528 value: "claude-sonnet",
529 }),
530 answer("session/set_config_option", {
531 configOptions: [modeOption, modelOption("claude-sonnet"), autopilotOption("off")],
532 }),
533 turnPrompt,
534 answer("session/prompt", { stopReason: "end_turn" }),
535 // "Kiro default" is Kiro's `auto`, so the session leaves the named model.
536 outbound("session/set_config_option", {
537 sessionId: SESSION_ID,
538 configId: "model",
539 value: "auto",
540 }),
541 answer("session/set_config_option", {
542 configOptions: [modeOption, modelOption("auto"), autopilotOption("off")],
543 }),
544 turnPrompt,
545 answer("session/prompt", { stopReason: "end_turn" }),
546 ...closeSession,
547 ],
548 drive: ({ events, startTurnOn }) =>
549 Effect.gen(function* () {
550 // The session already runs `auto`, so the first default turn sends no write.
551 for (const [ordinal, model] of [
552 [1, "default"],
553 [2, "claude-sonnet"],
554 [3, "default"],
555 ] as const) {
556 yield* startTurnOn(ordinal, model);
557 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
558 }
559 }),
560 }).pipe(Effect.provide(testLayer), Effect.scoped),
561 );
562
563 it.effect("sets the thread's effort once the model write advertises it, and after a switch", () =>
564 runKiroScript({
565 scenario: "effort-after-model",
566 frames: [
567 outbound("initialize"),
568 answer("initialize", KIRO_V3_INITIALIZE),
569 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
570 // On `auto` Kiro offers no effort option.
571 answer(
572 "session/new",
573 sessionSetup([modeOption, kiroModelOption("auto"), autopilotOption("on")]),
574 ),
575 // Turn 1: the model write's result is the first to advertise effort,
576 // at the model's default, so the chosen level goes out after it.
577 setConfig("model", "claude-opus-5.5"),
578 configResult([
579 modeOption,
580 kiroModelOption("claude-opus-5.5"),
581 autopilotOption("on"),
582 effortOption("medium", CLAUDE_EFFORT_LEVELS),
583 ]),
584 setConfig("effortLevel", "high"),
585 configResult([
586 modeOption,
587 kiroModelOption("claude-opus-5.5"),
588 autopilotOption("on"),
589 effortOption("high", CLAUDE_EFFORT_LEVELS),
590 ]),
591 setConfig("autopilot", "off"),
592 configResult([
593 modeOption,
594 kiroModelOption("claude-opus-5.5"),
595 autopilotOption("off"),
596 effortOption("high", CLAUDE_EFFORT_LEVELS),
597 ]),
598 turnPrompt,
599 answer("session/prompt", { stopReason: "end_turn" }),
600 // Turn 2: same model and effort, so nothing is written.
601 turnPrompt,
602 answer("session/prompt", { stopReason: "end_turn" }),
603 // Turn 3: a model without effort drops the option; no effort write.
604 setConfig("model", "claude-sonnet-4.5"),
605 configResult([modeOption, kiroModelOption("claude-sonnet-4.5"), autopilotOption("off")]),
606 turnPrompt,
607 answer("session/prompt", { stopReason: "end_turn" }),
608 // Turn 4: back on an effort model, Kiro starts at its default again.
609 setConfig("model", "claude-opus-5.5"),
610 configResult([
611 modeOption,
612 kiroModelOption("claude-opus-5.5"),
613 autopilotOption("off"),
614 effortOption("medium", CLAUDE_EFFORT_LEVELS),
615 ]),
616 setConfig("effortLevel", "high"),
617 configResult([
618 modeOption,
619 kiroModelOption("claude-opus-5.5"),
620 autopilotOption("off"),
621 effortOption("high", CLAUDE_EFFORT_LEVELS),
622 ]),
623 turnPrompt,
624 answer("session/prompt", { stopReason: "end_turn" }),
625 ...closeSession,
626 ],
627 model: "claude-opus-5.5",
628 options: reasoning("high"),
629 drive: ({ events, startTurnOn }) =>
630 Effect.gen(function* () {
631 for (const [ordinal, model] of [
632 [1, "claude-opus-5.5"],
633 [2, "claude-opus-5.5"],
634 [3, "claude-sonnet-4.5"],
635 [4, "claude-opus-5.5"],
636 ] as const) {
637 yield* startTurnOn(ordinal, model, reasoning("high"));
638 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
639 }
640 }),
641 }).pipe(Effect.provide(testLayer), Effect.scoped),
642 );
643
644 it.effect("waits for Kiro to advertise the model before setting its effort", () =>
645 runKiroScript({
646 scenario: "effort-after-advert",
647 frames: [
648 outbound("initialize"),
649 answer("initialize", KIRO_V3_INITIALIZE),
650 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
651 // Kiro 2.27.0 leaves `model` out of `session/new`.
652 answer("session/new", sessionSetup([modeOption, autopilotOption("on")])),
653 // Written before Kiro advertised anything: the result carries neither
654 // the model nor its effort, and an effort write now would be ignored.
655 setConfig("model", "claude-opus-5.5"),
656 configResult([modeOption, autopilotOption("on")]),
657 update({
658 sessionUpdate: "config_option_update",
659 configOptions: [
660 modeOption,
661 kiroModelOption("claude-opus-5.5"),
662 effortOption("medium", CLAUDE_EFFORT_LEVELS),
663 autopilotOption("on"),
664 ],
665 }),
666 setConfig("effortLevel", "high"),
667 configResult([
668 modeOption,
669 kiroModelOption("claude-opus-5.5"),
670 effortOption("high", CLAUDE_EFFORT_LEVELS),
671 autopilotOption("on"),
672 ]),
673 setConfig("autopilot", "off"),
674 configResult([
675 modeOption,
676 kiroModelOption("claude-opus-5.5"),
677 effortOption("high", CLAUDE_EFFORT_LEVELS),
678 autopilotOption("off"),
679 ]),
680 turnPrompt,
681 answer("session/prompt", { stopReason: "end_turn" }),
682 ...closeSession,
683 ],
684 model: "claude-opus-5.5",
685 options: reasoning("high"),
686 drive: ({ events, startTurnOn }) =>
687 Effect.gen(function* () {
688 yield* startTurnOn(1, "claude-opus-5.5", reasoning("high"));
689 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
690 }),
691 }).pipe(Effect.provide(testLayer), Effect.scoped),
692 );
693
694 it.effect("never sends an effort level the model does not offer", () =>
695 runKiroScript({
696 scenario: "effort-not-offered",
697 frames: [
698 outbound("initialize"),
699 answer("initialize", KIRO_V3_INITIALIZE),
700 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
701 answer(
702 "session/new",
703 sessionSetup([modeOption, kiroModelOption("auto"), autopilotOption("on")]),
704 ),
705 // Turn 1 asks for xhigh, which Opus 4.6 does not offer: no effort
706 // write, and the turn runs at Kiro's default.
707 setConfig("model", "claude-opus-4.6"),
708 configResult([
709 modeOption,
710 kiroModelOption("claude-opus-4.6"),
711 autopilotOption("on"),
712 effortOption("high", CLAUDE_4_6_EFFORT_LEVELS),
713 ]),
714 setConfig("autopilot", "off"),
715 configResult([
716 modeOption,
717 kiroModelOption("claude-opus-4.6"),
718 autopilotOption("off"),
719 effortOption("high", CLAUDE_4_6_EFFORT_LEVELS),
720 ]),
721 turnPrompt,
722 answer("session/prompt", { stopReason: "end_turn" }),
723 // Turn 2 picks max, which it offers.
724 setConfig("effortLevel", "max"),
725 configResult([
726 modeOption,
727 kiroModelOption("claude-opus-4.6"),
728 autopilotOption("off"),
729 effortOption("max", CLAUDE_4_6_EFFORT_LEVELS),
730 ]),
731 turnPrompt,
732 answer("session/prompt", { stopReason: "end_turn" }),
733 ...closeSession,
734 ],
735 model: "claude-opus-4.6",
736 options: reasoning("xhigh"),
737 drive: ({ events, startTurnOn }) =>
738 Effect.gen(function* () {
739 for (const [ordinal, effort] of [
740 [1, "xhigh"],
741 [2, "max"],
742 ] as const) {
743 yield* startTurnOn(ordinal, "claude-opus-4.6", reasoning(effort));
744 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
745 }
746 }),
747 }).pipe(Effect.provide(testLayer), Effect.scoped),
748 );
749
750 it.effect("answers a stale 'this session' approval with Kiro's allow_once", () =>
751 runKiroScript({
752 scenario: "stale-session-approval",
753 frames: [
754 ...openSessionFrames({
755 initialize: KIRO_V3_INITIALIZE,
756 configOptions: [modeOption, autopilotOption("on")],
757 }),
758 turnPrompt,
759 {
760 type: "emit_inbound",
761 label: "session/request_permission",
762 frame: {
763 kind: "request",
764 method: "session/request_permission",
765 // Kiro 2.27's options for a write (kiro_supervised_transcript.ndjson).
766 params: {
767 sessionId: SESSION_ID,
768 toolCall: { toolCallId: "call-1", title: "Write File", kind: "edit" },
769 options: [
770 { optionId: "accept", name: "Allow", kind: "allow_once" },
771 { optionId: "always-accept", name: "Always allow", kind: "allow_always" },
772 { optionId: "reject", name: "Deny", kind: "reject_once" },
773 ],
774 },
775 },
776 },
777 // The card offered no "this session" choice, so the answer is one-time
778 // rather than Kiro's workspace-wide `allow_always`.
779 {
780 type: "expect_outbound",
781 label: "session/request_permission.result",
782 frame: {
783 kind: "response",
784 method: "session/request_permission",
785 result: { outcome: { outcome: "selected", optionId: "accept" } },
786 },
787 },
788 answer("session/prompt", { stopReason: "end_turn" }),
789 ...closeSession,
790 ],
791 drive: ({ events, startTurn, respond }) =>
792 Effect.gen(function* () {
793 const pending = yield* Deferred.make<RuntimeRequestId>();
794 const turn = yield* collectTurn(
795 events.pipe(
796 Stream.tap((event) =>
797 event.type === "runtime_request.updated" &&
798 event.runtimeRequest.status === "pending"
799 ? Deferred.succeed(pending, event.runtimeRequest.id)
800 : Effect.void,
801 ),
802 ),
803 ).pipe(Effect.forkChild);
804 yield* startTurn;
805 yield* respond(yield* Deferred.await(pending), "acceptForSession");
806 assert.equal(terminalStatus(yield* Fiber.join(turn)), "completed");
807 }),
808 }).pipe(Effect.provide(testLayer), Effect.scoped),
809 );
810
811 it.effect("sends a model Kiro does not list, and fails with Kiro's own message", () =>
812 runKiroScript({
813 scenario: "unlisted-model",
814 model: "claude-opus-9",
815 frames: [
816 outbound("initialize"),
817 answer("initialize", KIRO_V3_INITIALIZE),
818 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
819 // Kiro has already listed its models, without the thread's custom one.
820 answer(
821 "session/new",
822 sessionSetup([modeOption, modelOption("auto"), autopilotOption("on")]),
823 ),
824 // Shapes from kiro-cli 2.27.0, probed live with this id: Kiro takes
825 // the write and fails the prompt.
826 setConfig("model", "claude-opus-9"),
827 configResult([modeOption, modelOption("claude-opus-9"), autopilotOption("on")]),
828 setConfig("autopilot", "off"),
829 configResult([modeOption, modelOption("claude-opus-9"), autopilotOption("off")]),
830 turnPrompt,
831 {
832 type: "emit_inbound",
833 label: "session/prompt.error",
834 frame: {
835 kind: "response",
836 method: "session/prompt",
837 error: {
838 code: -32000,
839 message:
840 "The model 'claude-opus-9' is not available. Please select a different model and try again.",
841 data: { errorType: "InvalidModelError", retryErrorType: "CLIENT_ERROR" },
842 },
843 },
844 },
845 ...closeSession,
846 ],
847 drive: ({ events, startTurn }) =>
848 Effect.gen(function* () {
849 yield* startTurn;
850 const terminal = (yield* collectTurn(events)).find(
851 (event) => event.type === "turn.terminal",
852 );
853 if (terminal?.type !== "turn.terminal" || terminal.status !== "failed") {
854 return yield* Effect.die("the unavailable model must fail its turn");
855 }
856 assert.include(terminal.failure.message, "'claude-opus-9' is not available");
857 }),
858 }).pipe(Effect.provide(testLayer), Effect.scoped),
859 );
860});
861
862describe("KiroAdapterV2 agents", () => {
863 // A user agent from `~/.kiro/agents` whose config sets `"model": "claude-sonnet-4.5"`.
864 const agentModeOption = (currentValue: string) => ({
865 ...modeOption,
866 currentValue,
867 options: [...modeOption.options, option("reviewer", "reviewer")],
868 });
869 const agent = (value: string) => [{ id: "mode", value }];
870
871 it.effect("selects the thread's agent before its model, which the agent would replace", () =>
872 runKiroScript({
873 scenario: "agent-then-model",
874 model: "claude-opus-5.5",
875 options: agent("reviewer"),
876 frames: [
877 outbound("initialize"),
878 answer("initialize", KIRO_V3_INITIALIZE),
879 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
880 answer(
881 "session/new",
882 sessionSetup([agentModeOption("vibe"), kiroModelOption("auto"), autopilotOption("on")]),
883 ),
884 // Shapes from kiro-cli 2.27.0, probed live: switching to an agent runs
885 // the agent's own model.
886 setConfig("mode", "reviewer"),
887 configResult([
888 agentModeOption("reviewer"),
889 kiroModelOption("claude-sonnet-4.5"),
890 autopilotOption("on"),
891 ]),
892 setConfig("model", "claude-opus-5.5"),
893 configResult([
894 agentModeOption("reviewer"),
895 kiroModelOption("claude-opus-5.5"),
896 autopilotOption("on"),
897 ]),
898 setConfig("autopilot", "off"),
899 configResult([
900 agentModeOption("reviewer"),
901 kiroModelOption("claude-opus-5.5"),
902 autopilotOption("off"),
903 ]),
904 turnPrompt,
905 answer("session/prompt", { stopReason: "end_turn" }),
906 // Turn 2 picks Spec; its model is the thread's again.
907 setConfig("mode", "spec"),
908 configResult([
909 agentModeOption("spec"),
910 kiroModelOption("claude-opus-5.5"),
911 autopilotOption("off"),
912 ]),
913 turnPrompt,
914 answer("session/prompt", { stopReason: "end_turn" }),
915 ...closeSession,
916 ],
917 drive: ({ events, startTurnOn }) =>
918 Effect.gen(function* () {
919 yield* startTurnOn(1, "claude-opus-5.5", agent("reviewer"));
920 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
921 yield* startTurnOn(2, "claude-opus-5.5", agent("spec"));
922 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
923 }),
924 }).pipe(Effect.provide(testLayer), Effect.scoped),
925 );
926
927 it.effect("puts Kiro default back after an agent switch moved the session off it", () =>
928 runKiroScript({
929 scenario: "agent-then-default-model",
930 options: agent("reviewer"),
931 frames: [
932 outbound("initialize"),
933 answer("initialize", KIRO_V3_INITIALIZE),
934 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
935 // A new session has not advertised `model` yet.
936 answer("session/new", sessionSetup([agentModeOption("vibe"), autopilotOption("on")])),
937 setConfig("mode", "reviewer"),
938 configResult([
939 agentModeOption("reviewer"),
940 kiroModelOption("claude-sonnet-4.5"),
941 autopilotOption("on"),
942 ]),
943 setConfig("model", "auto"),
944 configResult([agentModeOption("reviewer"), kiroModelOption("auto"), autopilotOption("on")]),
945 setConfig("autopilot", "off"),
946 configResult([
947 agentModeOption("reviewer"),
948 kiroModelOption("auto"),
949 autopilotOption("off"),
950 ]),
951 turnPrompt,
952 answer("session/prompt", { stopReason: "end_turn" }),
953 ...closeSession,
954 ],
955 drive: ({ events, startTurn }) =>
956 Effect.gen(function* () {
957 yield* startTurn;
958 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
959 }),
960 }).pipe(Effect.provide(testLayer), Effect.scoped),
961 );
962
963 it.effect("keeps the session's agent when the thread's agent no longer exists", () =>
964 runKiroScript({
965 scenario: "agent-removed",
966 options: agent("deleted-agent"),
967 frames: [
968 outbound("initialize"),
969 answer("initialize", KIRO_V3_INITIALIZE),
970 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
971 // No `mode` write: the replay agent fails on any frame not scripted here.
972 answer(
973 "session/new",
974 sessionSetup([agentModeOption("vibe"), kiroModelOption("auto"), autopilotOption("on")]),
975 ),
976 setConfig("autopilot", "off"),
977 configResult([agentModeOption("vibe"), kiroModelOption("auto"), autopilotOption("off")]),
978 turnPrompt,
979 answer("session/prompt", { stopReason: "end_turn" }),
980 ...closeSession,
981 ],
982 drive: ({ events, startTurn }) =>
983 Effect.gen(function* () {
984 yield* startTurn;
985 assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
986 }),
987 }).pipe(Effect.provide(testLayer), Effect.scoped),
988 );
989});
990
991describe("KiroAdapterV2 early Stop", () => {
992 // Stop holds its cancel until Kiro starts the prompt, for at most 2 s.
993 // Replay gates hold Kiro's frames until Stop is holding; reaching a gate's
994 // label means every frame before it was handled. The test clock moves only
995 // where a test adjusts it, so the 2 s bound releases a cancel nowhere else.
996 const kiroInfo = (label: string, kiro: Record<string, unknown>): Frame => ({
997 ...update({ sessionUpdate: "session_info_update", _meta: { kiro } }),
998 label,
999 });
1000 // Shapes recorded from Kiro 2.27 (fixtures/queued_turn/kiro_transcript.ndjson).
1001 const messageIdEcho = kiroInfo("message-id", {
1002 userMessageId: "message-2",
1003 kind: "user_message_id_assigned",
1004 });
1005 const turnStart = kiroInfo("turn-start", {
1006 turnStart: true,
1007 kind: "turn_start",
1008 messageId: "message-2-turn-start",
1009 });
1010 const cancel: Frame = {
1011 type: "expect_outbound",
1012 label: "session/cancel",
1013 frame: { kind: "notification", method: "session/cancel", params: { sessionId: SESSION_ID } },
1014 };
1015 const cancelled = answer("session/prompt", { stopReason: "cancelled" });
1016 const runningProviderTurnId = (event: ProviderAdapterV2Event) =>
1017 event.type === "provider_turn.updated" && event.providerTurn.status === "running"
1018 ? event.providerTurn.id
1019 : undefined;
1020
1021 /**
1022 * Runs a Kiro turn and presses Stop as soon as it runs. Once Stop holds its
1023 * cancel, `drive` gets the replay gate, a receipt for the cancel T3 sends,
1024 * and Stop's exit.
1025 */
1026 const earlyStop = (input: {
1027 readonly frames: ReadonlyArray<Frame>;
1028 readonly gates: ReadonlyArray<string>;
1029 readonly drive: (stop: {
1030 readonly replayGate: ProviderReplayGate;
1031 readonly cancelSent: Deferred.Deferred<void>;
1032 /** Completed once the cancel was written, right before Stop's acknowledgement wait. */
1033 readonly cancelWritten: Deferred.Deferred<void>;
1034 readonly stopFiber: Fiber.Fiber<Exit.Exit<void, ProviderAdapterV2Error>>;
1035 }) => Effect.Effect<void>;
1036 }) =>
1037 Effect.gen(function* () {
1038 const replayGate = makeProviderReplayGate(input.gates);
1039 const cancelHeld = yield* Deferred.make<void>();
1040 const cancelSent = yield* Deferred.make<void>();
1041 const cancelWritten = yield* Deferred.make<void>();
1042 yield* runKiroScript({
1043 scenario: "early-stop",
1044 replayGate,
1045 testHooks: {
1046 onCancelHeld: () => Deferred.succeed(cancelHeld, undefined).pipe(Effect.asVoid),
1047 },
1048 // `cancelSent` marks the decision to cancel, `cancelWritten` the write.
1049 wrapCancel: (send) =>
1050 Deferred.succeed(cancelSent, undefined).pipe(
1051 Effect.andThen(send),
1052 Effect.andThen(Deferred.succeed(cancelWritten, undefined)),
1053 Effect.asVoid,
1054 ),
1055 frames: [
1056 ...openSessionFrames({
1057 initialize: KIRO_V3_INITIALIZE,
1058 configOptions: [modeOption, autopilotOption("on")],
1059 }),
1060 turnPrompt,
1061 ...input.frames,
1062 ...closeSession,
1063 ],
1064 drive: ({ events, startTurn, interrupt }) =>
1065 Effect.gen(function* () {
1066 const running = yield* Deferred.make<ProviderTurnId>();
1067 const turn = yield* collectTurn(
1068 events.pipe(
1069 Stream.tap((event) => {
1070 const providerTurnId = runningProviderTurnId(event);
1071 return providerTurnId === undefined
1072 ? Effect.void
1073 : Deferred.succeed(running, providerTurnId);
1074 }),
1075 ),
1076 ).pipe(Effect.forkChild);
1077 yield* startTurn;
1078 const stop = yield* interrupt(yield* Deferred.await(running)).pipe(
1079 Effect.exit,
1080 Effect.forkChild,
1081 );
1082 yield* Deferred.await(cancelHeld);
1083 yield* input.drive({ replayGate, cancelSent, cancelWritten, stopFiber: stop });
1084 // Stop decides the run's outcome. A Stop Kiro never acknowledges leaves the
1085 // turn open, since Kiro may still be working, for the user to Force stop.
1086 if (Exit.isSuccess(yield* Fiber.join(stop))) {
1087 assert.equal(terminalStatus(yield* Fiber.join(turn)), "interrupted");
1088 } else {
1089 assert.isUndefined(turn.pollUnsafe());
1090 }
1091 }),
1092 });
1093 }).pipe(Effect.provide(testLayer), Effect.scoped);
1094
1095 /**
1096 * Lets Kiro's frames through one gate at a time. No cancel may go out
1097 * before the last gate's frame, and Stop must succeed.
1098 */
1099 const releaseInOrder = (input: {
1100 readonly frames: ReadonlyArray<Frame>;
1101 readonly gates: ReadonlyArray<string>;
1102 readonly expectCancel: boolean;
1103 }) =>
1104 earlyStop({
1105 frames: input.frames,
1106 gates: input.gates,
1107 drive: ({ replayGate, cancelSent, stopFiber }) =>
1108 Effect.gen(function* () {
1109 for (const [index, label] of input.gates.entries()) {
1110 replayGate.release(label);
1111 const next = input.gates[index + 1];
1112 if (next === undefined) continue;
1113 yield* Effect.promise(() => replayGate.waitForReached(next));
1114 assert.isFalse(
1115 yield* Deferred.isDone(cancelSent),
1116 `Stop must still hold its cancel after ${label}`,
1117 );
1118 }
1119 assert.isTrue(Exit.isSuccess(yield* Fiber.join(stopFiber)));
1120 assert.equal(yield* Deferred.isDone(cancelSent), input.expectCancel);
1121 }),
1122 });
1123
1124 it.effect("holds Stop past Kiro's message-id echo until its turn_start", () =>
1125 releaseInOrder({
1126 gates: ["message-id", "turn-start"],
1127 expectCancel: true,
1128 frames: [
1129 messageIdEcho,
1130 kiroInfo("focus", {
1131 focus: { status: "in_progress" },
1132 kind: "focus_update",
1133 status: "in_progress",
1134 }),
1135 turnStart,
1136 cancel,
1137 cancelled,
1138 ],
1139 }),
1140 );
1141
1142 it.effect("does not take the previous prompt's late context usage as the start", () =>
1143 releaseInOrder({
1144 // Kiro reports the finished prompt's context usage after answering it,
1145 // so it can arrive once the next `session/prompt` went out.
1146 gates: ["previous-context-usage", "turn-start"],
1147 expectCancel: true,
1148 frames: [
1149 kiroInfo("previous-context-usage", {
1150 contextUsage: { usagePercentage: 8.8 },
1151 kind: "context_usage",
1152 usagePercentage: 8.8,
1153 }),
1154 turnStart,
1155 cancel,
1156 cancelled,
1157 ],
1158 }),
1159 );
1160
1161 it.effect("does not take Kiro's background cloud-config fetch as the start", () =>
1162 releaseInOrder({
1163 // Kiro 2.31 fetches an Identity Center account's cloud config as a tool
1164 // call of its own, which can arrive once `session/prompt` went out
1165 // (frames reported by @hstern; the tool call id is invented).
1166 gates: ["cloud-config", "cloud-config-done", "turn-start"],
1167 expectCancel: true,
1168 frames: [
1169 {
1170 ...update({
1171 sessionUpdate: "tool_call",
1172 toolCallId: "tooluse_cloud_config",
1173 title: "Fetching your cloud config",
1174 kind: "other",
1175 status: "in_progress",
1176 _meta: { kiro: { toolId: "fetch_cloud_config" } },
1177 }),
1178 label: "cloud-config",
1179 },
1180 messageIdEcho,
1181 {
1182 ...update({
1183 sessionUpdate: "tool_call_update",
1184 toolCallId: "tooluse_cloud_config",
1185 status: "completed",
1186 rawOutput: { kind: "upToDate" },
1187 }),
1188 label: "cloud-config-done",
1189 },
1190 turnStart,
1191 cancel,
1192 cancelled,
1193 ],
1194 }),
1195 );
1196
1197 it.effect("cancels on Kiro's first reply text when it sends no turn_start", () =>
1198 releaseInOrder({
1199 gates: ["message-id", "reply"],
1200 expectCancel: true,
1201 frames: [
1202 messageIdEcho,
1203 {
1204 ...update({
1205 sessionUpdate: "agent_message_chunk",
1206 content: { type: "text", text: "On it" },
1207 }),
1208 label: "reply",
1209 },
1210 cancel,
1211 cancelled,
1212 ],
1213 }),
1214 );
1215
1216 it.effect("cancels 2 s after the prompt when Kiro shows nothing but the echo", () =>
1217 earlyStop({
1218 gates: ["message-id", "focus"],
1219 frames: [
1220 messageIdEcho,
1221 kiroInfo("focus", {
1222 focus: { status: "in_progress" },
1223 kind: "focus_update",
1224 status: "in_progress",
1225 }),
1226 cancel,
1227 cancelled,
1228 ],
1229 drive: ({ replayGate, cancelSent, stopFiber }) =>
1230 Effect.gen(function* () {
1231 replayGate.release("message-id");
1232 yield* Effect.promise(() => replayGate.waitForReached("focus"));
1233 yield* TestClock.adjust("1999 millis");
1234 assert.isFalse(yield* Deferred.isDone(cancelSent), "the echo must not release Stop");
1235 yield* TestClock.adjust("1 millis");
1236 yield* Deferred.await(cancelSent);
1237 // Kiro acknowledges, and Stop still has its own time to see it.
1238 replayGate.release("focus");
1239 assert.isTrue(Exit.isSuccess(yield* Fiber.join(stopFiber)));
1240 }),
1241 }),
1242 );
1243
1244 it.effect("fails a Stop Kiro never acknowledges after the 10 s wait and keeps the turn", () =>
1245 earlyStop({
1246 gates: [],
1247 // Kiro never starts the prompt and never answers it.
1248 frames: [messageIdEcho, cancel],
1249 drive: ({ cancelWritten, stopFiber }) =>
1250 Effect.gen(function* () {
1251 yield* TestClock.adjust("2 seconds");
1252 yield* Deferred.await(cancelWritten);
1253 // The acknowledgement wait gets its full 10 s after the cancel.
1254 yield* TestClock.adjust("9999 millis");
1255 assert.isUndefined(stopFiber.pollUnsafe(), "Stop must still wait for Kiro's answer");
1256 yield* TestClock.adjust("1 millis");
1257 const error = Exit.findErrorOption(yield* Fiber.join(stopFiber));
1258 assert.equal(Option.getOrUndefined(error)?._tag, "ProviderAdapterInterruptTimeoutError");
1259 }),
1260 }),
1261 );
1262
1263 it.effect("sends no cancel when Kiro finishes the prompt before starting it", () =>
1264 releaseInOrder({
1265 gates: ["session/prompt.result"],
1266 expectCancel: false,
1267 frames: [answer("session/prompt", { stopReason: "end_turn" })],
1268 }),
1269 );
1270});
1271
1272describe("KiroAdapterV2 MCP tools", () => {
1273 // Probed live on Kiro 2.33.1 against a stdio MCP server named t3-code, without
1274 // the permission round trip. Kiro titles the call "@t3-code/html_render", names
1275 // it in `tool_id`, and keeps only the result's text.
1276 it.effect("shows T3's html_render as T3's tool with the page T3 published", () =>
1277 Effect.gen(function* () {
1278 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
1279 const toolCallId = "tooluse_4N3LnlI9PRlphUtzdBQGDD";
1280 const args = { html: "<p>hi</p>", title: "Probe", height: 120 };
1281 const rawInput = { tool_id: "t3-code::html_render", arguments: args };
1282 const kiroMeta = { kiro: { serverName: "t3-code", toolOrigin: "default" } };
1283 const page = { attachmentId: "kiro-mcp-html-render-page", title: "Probe", height: 120 };
1284 yield* runKiroScript({
1285 scenario: "mcp-html-render",
1286 frames: [
1287 ...openSessionFrames({
1288 initialize: KIRO_V3_INITIALIZE,
1289 configOptions: [modeOption, autopilotOption("on")],
1290 }),
1291 turnPrompt,
1292 update({
1293 sessionUpdate: "tool_call",
1294 toolCallId,
1295 title: "@t3-code/html_render",
1296 kind: "other",
1297 status: "pending",
1298 rawInput,
1299 _meta: kiroMeta,
1300 }),
1301 update({
1302 sessionUpdate: "tool_call_update",
1303 toolCallId,
1304 status: "in_progress",
1305 rawInput,
1306 _meta: kiroMeta,
1307 }),
1308 update({
1309 sessionUpdate: "tool_call_update",
1310 toolCallId,
1311 status: "completed",
1312 title: "@t3-code/html_render",
1313 rawInput,
1314 rawOutput: { response: "Rendered.", imageBase64Urls: [], message: "Rendered." },
1315 content: [{ type: "content", content: { type: "text", text: "Rendered." } }],
1316 _meta: kiroMeta,
1317 }),
1318 answer("session/prompt", { stopReason: "end_turn" }),
1319 ...closeSession,
1320 ],
1321 drive: ({ events, startTurn }) =>
1322 Effect.gen(function* () {
1323 // T3's MCP server records the page when Kiro calls html_render.
1324 yield* mcpSessions.recordHtmlRender(
1325 ThreadId.make("thread-kiro-mcp-html-render"),
1326 args,
1327 page,
1328 );
1329 yield* startTurn;
1330 const turn = yield* collectTurn(events);
1331 const items = turn.flatMap((event) =>
1332 event.type === "turn_item.updated" &&
1333 event.turnItem.type === "dynamic_tool" &&
1334 event.turnItem.nativeItemRef?.nativeId === toolCallId
1335 ? [event.turnItem]
1336 : [],
1337 );
1338 const completed = items.at(-1);
1339 assert.deepEqual(
1340 items.map((item) => item.toolName),
1341 items.map(() => "t3-code.html_render"),
1342 );
1343 assert.equal(completed?.status, "completed");
1344 assert.deepEqual(completed?.input, args);
1345 assert.deepEqual(
1346 completed === undefined ? undefined : htmlRenderFromToolItem(completed),
1347 page,
1348 );
1349 }),
1350 });
1351 }).pipe(Effect.provide(testLayer), Effect.scoped),
1352 );
1353});
1354
1355describe("KiroAdapterV2 unknown model", () => {
1356 // Recorded live from Kiro 2.27.0 with the thread on a model id the account
1357 // lacks. Kiro has not advertised `model` yet, so T3 sends the id; Kiro
1358 // accepts it and fails the prompt with its own explanation.
1359 it.effect("fails the turn with Kiro's own unavailable-model message", () =>
1360 Effect.gen(function* () {
1361 const recorded = yield* readProviderReplayTranscript(
1362 new URL("../testkit/fixtures/kiro_unknown_model/kiro_transcript.ndjson", import.meta.url),
1363 );
1364 const firstPromptAnswer = recorded.entries.findIndex(
1365 (entry) =>
1366 entry.type === "emit_inbound" &&
1367 (entry.frame as { kind?: unknown; method?: unknown }).kind === "response" &&
1368 (entry.frame as { method?: unknown }).method === "session/prompt",
1369 );
1370 const frames = recorded.entries.slice(0, firstPromptAnswer + 1).map((entry) => {
1371 const frame = entry.type === "runtime_exit" ? undefined : (entry.frame as Frame);
1372 return frame?.method === "session/prompt" && frame.kind === "request"
1373 ? { ...entry, frame: { ...frame, params: "<any>" } }
1374 : entry;
1375 });
1376 const sessionId = recorded.entries.flatMap((entry) => {
1377 const frame = entry.type === "runtime_exit" ? undefined : (entry.frame as Frame);
1378 const result = frame?.result as { sessionId?: unknown } | undefined;
1379 return frame?.method === "session/new" && typeof result?.sessionId === "string"
1380 ? [result.sessionId]
1381 : [];
1382 })[0];
1383 yield* runKiroScript({
1384 scenario: "unknown-model",
1385 model: "not-a-kiro-model",
1386 // As recorded: Kiro already runs on Autopilot, so T3 writes only the model.
1387 runtimeMode: "full-access",
1388 frames: [
1389 ...(frames as ReadonlyArray<Frame>),
1390 outbound("session/close", { sessionId }),
1391 answer("session/close", {}),
1392 ],
1393 drive: ({ events, startTurn }) =>
1394 Effect.gen(function* () {
1395 yield* startTurn;
1396 const turn = yield* collectTurn(events);
1397 const terminal = turn.find((event) => event.type === "turn.terminal");
1398 if (terminal?.type !== "turn.terminal" || terminal.status !== "failed") {
1399 return yield* Effect.die("the unknown model must fail its turn");
1400 }
1401 assert.include(terminal.failure.message, "not-a-kiro-model");
1402 assert.include(terminal.failure.message, "is not available");
1403 assert.equal(terminal.failure.code, "-32000");
1404 }),
1405 });
1406 }).pipe(Effect.provide(testLayer), Effect.scoped),
1407 );
1408});
1409
1410describe("Kiro permission policy", () => {
1411 const policy = (input: {
1412 readonly runtimeMode: RuntimeMode;
1413 readonly approvalPolicy?: unknown;
1414 readonly sandboxPolicy?: unknown;
1415 }) =>
1416 ProviderAdapterV2RuntimePolicy.make({
1417 interactionMode: "default",
1418 cwd: "/workspace",
1419 ...input,
1420 });
1421
1422 it("keeps Autopilot on only for an unrestricted Full access thread", () => {
1423 assert.equal(kiroAutopilotValue(policy({ runtimeMode: "full-access" })), "on");
1424 assert.equal(kiroAutopilotValue(policy({ runtimeMode: "approval-required" })), "off");
1425 // An explicit override restricts Full access, so Kiro's review stays on.
1426 assert.equal(
1427 kiroAutopilotValue(policy({ runtimeMode: "full-access", approvalPolicy: "on-request" })),
1428 "off",
1429 );
1430 assert.equal(
1431 kiroAutopilotValue(
1432 policy({ runtimeMode: "full-access", sandboxPolicy: { type: "readOnly" } }),
1433 ),
1434 "off",
1435 );
1436 });
1437
1438 const writeRequest = (
1439 options: EffectAcpSchema.RequestPermissionRequest["options"],
1440 ): EffectAcpSchema.RequestPermissionRequest => ({
1441 sessionId: "session-1",
1442 toolCall: { toolCallId: "call-1", title: "Write File", kind: "edit" },
1443 options,
1444 });
1445
1446 it("never lets a policy approval persist Kiro's workspace-wide allow_always", () => {
1447 const fullAccess = policy({ runtimeMode: "full-access" });
1448 assert.equal(
1449 kiroPermissionDisposition(
1450 fullAccess,
1451 writeRequest([
1452 { optionId: "accept", name: "Allow", kind: "allow_once" },
1453 { optionId: "always-accept", name: "Always allow", kind: "allow_always" },
1454 ]),
1455 ),
1456 "allow",
1457 );
1458 // With no one-time choice, approving would save a rule beyond the thread,
1459 // so the request goes to the user, whose card can still allow it.
1460 const alwaysOnly = writeRequest([
1461 { optionId: "always-accept", name: "Always allow", kind: "allow_always" },
1462 { optionId: "reject", name: "Deny", kind: "reject_once" },
1463 ]);
1464 assert.equal(kiroPermissionDisposition(fullAccess, alwaysOnly), "ask");
1465 assert.deepEqual(
1466 kiroApprovalOptions(alwaysOnly).map((option) => option.decision),
1467 ["acceptForSession", "decline", "cancel"],
1468 );
1469 });
1470});