1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import { KiroSettings } from "@t3tools/provider-kiro/settings";
5import * as Deferred from "effect/Deferred";
6import type * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";
10import * as Queue from "effect/Queue";
11import * as Schema from "effect/Schema";
12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15import * as TestClock from "effect/testing/TestClock";
16
17import {
18 checkKiroProviderStatus,
19 kiroAgentDescriptor,
20 kiroDefaultModelId,
21} from "@t3tools/provider-kiro/testing";
22
23const encoder = new TextEncoder();
24const settings = Schema.decodeSync(KiroSettings)({ enabled: true });
25
26interface CliOutput {
27 readonly stdout: string;
28 readonly code: number;
29 /** Holds stdout and the exit back this long, like a CLI slow to answer. */
30 readonly delay?: Duration.Input;
31}
32
33function processHandle(output: CliOutput) {
34 const answered = Effect.sleep(output.delay ?? 0);
35 return ChildProcessSpawner.makeHandle({
36 pid: ChildProcessSpawner.ProcessId(900_000_002),
37 exitCode: Effect.as(answered, ChildProcessSpawner.ExitCode(output.code)),
38 isRunning: Effect.succeed(false),
39 kill: () => Effect.void,
40 unref: Effect.succeed(Effect.void),
41 stdin: Sink.drain,
42 stdout: Stream.unwrap(
43 Effect.as(
44 answered,
45 output.stdout.length === 0 ? Stream.empty : Stream.succeed(encoder.encode(output.stdout)),
46 ),
47 ),
48 stderr: Stream.empty,
49 all: Stream.empty,
50 getInputFd: () => Sink.drain,
51 getOutputFd: () => Stream.empty,
52 });
53}
54
55const KIRO_ACP_ARGS = "acp --agent-engine=v3 --auth-method=cli";
56const decodeRequest = Schema.decodeUnknownSync(
57 Schema.fromJsonString(
58 Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]), method: Schema.String }),
59 ),
60);
61
62/**
63 * A `kiro-cli acp` that answers `initialize` and `_kiro/config/template` and
64 * fails anything else, so a probe that opened a session would fail.
65 */
66const acpProcess = (template: unknown) =>
67 Effect.gen(function* () {
68 const output = yield* Queue.unbounded<Uint8Array>();
69 const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
70 const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe(
71 Effect.andThen(Queue.shutdown(output)),
72 Effect.asVoid,
73 );
74 const reply = (line: string) => {
75 const request = decodeRequest(line);
76 const response =
77 request.method === "initialize"
78 ? { result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } }
79 : request.method === "_kiro/config/template"
80 ? { result: template }
81 : { error: { code: -32601, message: `${request.method} is not scripted` } };
82 return Queue.offer(
83 output,
84 encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, ...response })}\n`),
85 );
86 };
87 return ChildProcessSpawner.makeHandle({
88 pid: ChildProcessSpawner.ProcessId(900_000_003),
89 exitCode: Deferred.await(exited),
90 isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
91 kill: () => terminate,
92 unref: Effect.succeed(Effect.void),
93 stdin: Sink.forEach((bytes: Uint8Array) =>
94 Effect.forEach(
95 new TextDecoder()
96 .decode(bytes)
97 .split("\n")
98 .filter((line) => line.trim().length > 0),
99 reply,
100 ),
101 ),
102 stdout: Stream.fromQueue(output),
103 stderr: Stream.empty,
104 all: Stream.empty,
105 getInputFd: () => Sink.drain,
106 getOutputFd: () => Stream.empty,
107 });
108 });
109
110/** Answers `kiro-cli` invocations by their arguments and records which ran. */
111function kiroCli(outputs: Record<string, CliOutput>, template?: unknown) {
112 const invoked: Array<string> = [];
113 const spawner = ChildProcessSpawner.make((command) => {
114 const args = ChildProcess.isStandardCommand(command) ? command.args.join(" ") : "";
115 invoked.push(args);
116 return args === KIRO_ACP_ARGS && template !== undefined
117 ? acpProcess(template)
118 : Effect.succeed(processHandle(outputs[args] ?? { stdout: "", code: 127 }));
119 });
120 return { invoked, spawner };
121}
122
123const check = (
124 outputs: Record<string, CliOutput>,
125 options: { readonly template?: unknown; readonly settings?: KiroSettings } = {},
126) =>
127 Effect.gen(function* () {
128 const cli = kiroCli(outputs, options.template);
129 const snapshot = yield* checkKiroProviderStatus(options.settings ?? settings, {}).pipe(
130 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, cli.spawner),
131 );
132 return { snapshot, invoked: cli.invoked };
133 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
134
135describe("KiroProvider", () => {
136 it.effect.each([
137 {
138 label: "answers after 6 s",
139 delay: "6 seconds",
140 status: "ready",
141 version: "2.27.0",
142 message: undefined,
143 },
144 {
145 label: "never answers",
146 delay: "Infinity",
147 status: "error",
148 version: null,
149 message: "Failed to run `kiro-cli --version`.",
150 },
151 ] as const)("gives `kiro-cli --version` 10 s when it $label", (row) =>
152 Effect.gen(function* () {
153 const fiber = yield* check({
154 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0, delay: row.delay },
155 "whoami --format json": { stdout: '{"accountType":"ApiKey","email":null}\n', code: 0 },
156 }).pipe(Effect.forkChild);
157 yield* TestClock.adjust("10 seconds");
158 const { snapshot } = yield* Fiber.join(fiber);
159 assert.equal(snapshot.status, row.status);
160 assert.equal(snapshot.version, row.version);
161 assert.equal(snapshot.message, row.message);
162 }),
163 );
164
165 it.effect("reports a CLI whose version probe exits nonzero as failing to run", () =>
166 Effect.gen(function* () {
167 const { snapshot, invoked } = yield* check({
168 "--version": { stdout: "kiro-cli 2.27.0\n", code: 1 },
169 });
170 assert.equal(snapshot.status, "error");
171 assert.isTrue(snapshot.installed);
172 assert.equal(snapshot.message, "Kiro CLI is installed but failed to run.");
173 // A broken CLI is not asked for its sign-in.
174 assert.deepEqual(invoked, ["--version"]);
175 }),
176 );
177
178 // `whoami` outputs recorded from Kiro CLI 2.27.0.
179 it.effect("asks for kiro-cli login when Kiro is signed out", () =>
180 Effect.gen(function* () {
181 const { snapshot } = yield* check({
182 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
183 "whoami --format json": { stdout: '{"account":null}\n', code: 1 },
184 });
185 assert.equal(snapshot.status, "error");
186 assert.equal(snapshot.version, "2.27.0");
187 assert.equal(snapshot.auth.status, "unauthenticated");
188 assert.include(snapshot.message ?? "", "kiro-cli login");
189 }),
190 );
191
192 it.effect("is ready once Kiro reports a signed-in account", () =>
193 Effect.gen(function* () {
194 const { snapshot } = yield* check({
195 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
196 "whoami --format json": { stdout: '{"accountType":"ApiKey","email":null}\n', code: 0 },
197 });
198 assert.equal(snapshot.status, "ready");
199 assert.equal(snapshot.auth.status, "authenticated");
200 // The version advisory is what the compatibility check and update UI read.
201 assert.equal(snapshot.versionAdvisory?.currentVersion, "2.27.0");
202 assert.isFalse(snapshot.versionAdvisory?.canUpdate);
203 }),
204 );
205
206 // Shape of `kiro-cli chat --list-models --format json` on Kiro CLI 2.27.0, trimmed to three models.
207 const KIRO_MODEL_LIST = JSON.stringify({
208 models: [
209 { model_name: "auto", model_id: "auto", description: "Models chosen by task" },
210 { model_name: "claude-sonnet-4.5", model_id: "claude-sonnet-4.5" },
211 { model_name: "claude-haiku-4.5", model_id: "claude-haiku-4.5" },
212 ],
213 default_model: "auto",
214 });
215
216 it.effect("lists the signed-in account's models with auto as the Kiro default", () =>
217 Effect.gen(function* () {
218 const { snapshot } = yield* check({
219 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
220 "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 },
221 "chat --list-models --format json": { stdout: `${KIRO_MODEL_LIST}\n`, code: 0 },
222 });
223 assert.deepEqual(
224 snapshot.models.map((model) => [model.slug, model.name, model.isDefault === true]),
225 [
226 ["default", "auto", true],
227 ["claude-sonnet-4.5", "claude-sonnet-4.5", false],
228 ["claude-haiku-4.5", "claude-haiku-4.5", false],
229 ],
230 );
231 }),
232 );
233
234 it.effect("names Kiro default after the model Kiro's list names as its default", () =>
235 Effect.gen(function* () {
236 const signedIn = {
237 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
238 "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 },
239 };
240 const listed = yield* check({
241 ...signedIn,
242 "chat --list-models --format json": {
243 stdout: KIRO_MODEL_LIST.replace(
244 '"default_model":"auto"',
245 '"default_model":"claude-sonnet-4.5"',
246 ),
247 code: 0,
248 },
249 });
250 assert.deepEqual(
251 listed.snapshot.models.map((model) => [model.slug, model.name]),
252 [
253 ["auto", "auto"],
254 ["default", "claude-sonnet-4.5"],
255 ["claude-haiku-4.5", "claude-haiku-4.5"],
256 ],
257 );
258 assert.equal(kiroDefaultModelId(listed.snapshot.models), "claude-sonnet-4.5");
259 // An unparsable list keeps the built-in entry, which falls back to `auto`.
260 const unlisted = yield* check({
261 ...signedIn,
262 "chat --list-models --format json": { stdout: "not json\n", code: 0 },
263 });
264 assert.equal(unlisted.snapshot.models[0]?.name, "Kiro default");
265 assert.equal(kiroDefaultModelId(unlisted.snapshot.models), "auto");
266 }),
267 );
268
269 it.effect("lists Kiro default even when Kiro's model list leaves its default out", () =>
270 Effect.gen(function* () {
271 const { snapshot } = yield* check({
272 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
273 "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 },
274 "chat --list-models --format json": {
275 stdout: JSON.stringify({ models: [{ model_id: "claude-sonnet-4.5" }] }),
276 code: 0,
277 },
278 });
279 assert.deepEqual(
280 snapshot.models.map((model) => [model.slug, model.name, model.isDefault === true]),
281 [
282 ["default", "Kiro default", true],
283 ["claude-sonnet-4.5", "claude-sonnet-4.5", false],
284 ],
285 );
286 assert.equal(kiroDefaultModelId(snapshot.models), "auto");
287 }),
288 );
289
290 // `--list-models` names no effort levels, so the picker's come from Kiro's ids.
291 const KIRO_EFFORT_MODEL_LIST = JSON.stringify({
292 models: [
293 "auto",
294 "claude-fable-5.1",
295 "claude-opus-5.5",
296 "claude-opus-4.6",
297 "kimi-k3",
298 "claude-sonnet-4.5",
299 ].map((id) => ({
300 model_name: id,
301 model_id: id,
302 })),
303 default_model: "auto",
304 });
305
306 it.effect("offers each model only the reasoning levels Kiro takes for it", () =>
307 Effect.gen(function* () {
308 const { snapshot } = yield* check({
309 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
310 "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 },
311 "chat --list-models --format json": { stdout: KIRO_EFFORT_MODEL_LIST, code: 0 },
312 });
313 const reasoning = snapshot.models.map((model) => {
314 const descriptors = model.capabilities?.optionDescriptors ?? [];
315 const select = descriptors.find((descriptor) => descriptor.id === "reasoningEffort");
316 return select?.type === "select"
317 ? {
318 slug: model.slug,
319 label: select.label,
320 levels: select.options.map((choice) => choice.id),
321 defaults: select.options.filter((choice) => choice.isDefault).map((c) => c.id),
322 currentValue: select.currentValue,
323 }
324 : { slug: model.slug, descriptors: descriptors.length };
325 });
326 assert.deepEqual(reasoning, [
327 // Kiro default and models without effort get no Reasoning select.
328 { slug: "default", descriptors: 0 },
329 {
330 slug: "claude-fable-5.1",
331 label: "Reasoning",
332 levels: ["low", "medium", "high", "xhigh", "max"],
333 defaults: ["high"],
334 currentValue: "high",
335 },
336 {
337 slug: "claude-opus-5.5",
338 label: "Reasoning",
339 levels: ["low", "medium", "high", "xhigh", "max"],
340 defaults: ["medium"],
341 currentValue: "medium",
342 },
343 // Opus 4.6 has no xhigh, and Kiro runs it at high by default.
344 {
345 slug: "claude-opus-4.6",
346 label: "Reasoning",
347 levels: ["low", "medium", "high", "max"],
348 defaults: ["high"],
349 currentValue: "high",
350 },
351 // Kimi K3 skips medium and runs at max by default.
352 {
353 slug: "kimi-k3",
354 label: "Reasoning",
355 levels: ["low", "high", "max"],
356 defaults: ["max"],
357 currentValue: "max",
358 },
359 { slug: "claude-sonnet-4.5", descriptors: 0 },
360 ]);
361 }),
362 );
363
364 it.effect("gives every model, custom ones too, the agent picker Kiro's template lists", () =>
365 Effect.gen(function* () {
366 const { snapshot } = yield* check(
367 {
368 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
369 "whoami --format json": { stdout: '{"accountType":"SocialGitHub"}\n', code: 0 },
370 "chat --list-models --format json": { stdout: KIRO_MODEL_LIST, code: 0 },
371 },
372 {
373 template: {
374 configOptions: [
375 {
376 type: "select",
377 id: "mode",
378 name: "Mode",
379 currentValue: "vibe",
380 options: [
381 { value: "vibe", name: "Default" },
382 { value: "reviewer", name: "reviewer" },
383 ],
384 },
385 ],
386 },
387 settings: { ...settings, customModels: ["my-model"] },
388 },
389 );
390 assert.deepEqual(
391 snapshot.models.map((model) => [
392 model.slug,
393 model.capabilities?.optionDescriptors?.flatMap((descriptor) =>
394 descriptor.type === "select" && descriptor.id === "mode"
395 ? descriptor.options.map((choice) => choice.id)
396 : [],
397 ),
398 ]),
399 [
400 ["default", ["vibe", "reviewer"]],
401 ["claude-sonnet-4.5", ["vibe", "reviewer"]],
402 ["claude-haiku-4.5", ["vibe", "reviewer"]],
403 ["my-model", ["vibe", "reviewer"]],
404 ],
405 );
406 }),
407 );
408
409 it("offers Kiro's agents from its config template, Default first", () => {
410 // `_kiro/config/template` from kiro-cli 2.27.0 with two user agents in
411 // `~/.kiro/agents`, trimmed to three bundled agents and the `mode` option.
412 const template = {
413 modes: { currentModeId: "vibe", availableModes: [] },
414 configOptions: [
415 {
416 type: "select",
417 id: "mode",
418 name: "Mode",
419 category: "mode",
420 currentValue: "vibe",
421 options: [
422 { value: "vibe", name: "Default", description: "General coding assistance" },
423 { value: "spec", name: "Spec", description: "Structured feature development" },
424 { value: "plan", name: "Plan", description: "Plan-only mode" },
425 { value: "global-probe", name: "global-probe", description: "Global probe agent" },
426 { value: "planner", name: "planner", description: null },
427 ],
428 },
429 {
430 type: "select",
431 id: "autopilot",
432 name: "Autopilot",
433 currentValue: "on",
434 options: [{ value: "on", name: "Autopilot" }],
435 },
436 ],
437 };
438 const descriptor = kiroAgentDescriptor(template);
439 assert.deepEqual(descriptor, {
440 id: "mode",
441 label: "Agent",
442 type: "select",
443 currentValue: "vibe",
444 options: [
445 { id: "vibe", label: "Default", description: "General coding assistance", isDefault: true },
446 { id: "spec", label: "Spec", description: "Structured feature development" },
447 { id: "plan", label: "Plan", description: "Plan-only mode" },
448 { id: "global-probe", label: "global-probe", description: "Global probe agent" },
449 { id: "planner", label: "planner" },
450 ],
451 });
452 // The default follows the agent Kiro starts sessions on.
453 const onSpec = kiroAgentDescriptor({
454 configOptions: [{ ...template.configOptions[0], currentValue: "spec" }],
455 });
456 assert.equal(onSpec?.type === "select" ? onSpec.currentValue : undefined, "spec");
457 assert.deepEqual(
458 onSpec?.type === "select" ? onSpec.options.filter((choice) => choice.isDefault) : [],
459 [
460 {
461 id: "spec",
462 label: "Spec",
463 description: "Structured feature development",
464 isDefault: true,
465 },
466 ],
467 );
468 // A template without agents to choose between gives no picker.
469 assert.isUndefined(kiroAgentDescriptor({ configOptions: [] }));
470 assert.isUndefined(kiroAgentDescriptor("not a template"));
471 });
472
473 it.effect("never lists models while signed out, where listing would start a browser login", () =>
474 Effect.gen(function* () {
475 const { snapshot, invoked } = yield* check({
476 "--version": { stdout: "kiro-cli 2.27.0\n", code: 0 },
477 "whoami --format json": { stdout: '{"account":null}\n', code: 1 },
478 "chat --list-models --format json": { stdout: `${KIRO_MODEL_LIST}\n`, code: 0 },
479 });
480 assert.notInclude(invoked, "chat --list-models --format json");
481 assert.deepEqual(
482 snapshot.models.map((model) => model.slug),
483 ["default"],
484 );
485 }),
486 );
487});