Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as TestClock from "effect/testing/TestClock";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import type * as EffectAcpSchema from "effect-acp/compat";
Expand Down Expand Up @@ -57,8 +58,10 @@ import {
KIRO_PROVIDER,
kiroApprovalOptions,
kiroAutopilotValue,
type KiroSkills,
kiroPermissionDisposition,
makeKiroAdapterV2,
makeKiroSkills,
} from "@t3tools/provider-kiro/testing";

const testLayer = Layer.mergeAll(
Expand Down Expand Up @@ -307,6 +310,14 @@ const runKiroScript = Effect.fn("runKiroScript")(function* (input: {
readonly wrapCancel?: (
send: AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"],
) => AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"];
/** Wraps the runtime's `session/prompt` send. */
readonly wrapPrompt?: (
send: AcpSessionRuntime.AcpSessionRuntime["Service"]["prompt"],
) => AcpSessionRuntime.AcpSessionRuntime["Service"]["prompt"];
/** The user's message text for every turn. */
readonly text?: string;
/** Where the adapter records advertised skills. */
readonly skills?: KiroSkills;
readonly drive: (session: {
readonly events: Stream.Stream<ProviderAdapterV2Event, ProviderAdapterV2Error>;
readonly startTurn: Effect.Effect<void, ProviderAdapterV2Error>;
Expand Down Expand Up @@ -360,13 +371,14 @@ const runKiroScript = Effect.fn("runKiroScript")(function* (input: {
fileSystem,
...(input.replayGate === undefined ? {} : { replayGate: input.replayGate }),
})(runtimeInput).pipe(
Effect.map((runtime) =>
input.wrapCancel === undefined
? runtime
: { ...runtime, cancel: input.wrapCancel(runtime.cancel) },
),
Effect.map((runtime) => ({
...runtime,
...(input.wrapCancel === undefined ? {} : { cancel: input.wrapCancel(runtime.cancel) }),
...(input.wrapPrompt === undefined ? {} : { prompt: input.wrapPrompt(runtime.prompt) }),
})),
),
...(input.testHooks === undefined ? {} : { testHooks: input.testHooks }),
...(input.skills === undefined ? {} : { skills: input.skills }),
});
// Held frames must not outlive a failed drive and wedge teardown.
yield* Effect.addFinalizer(() => Effect.sync(() => input.replayGate?.releaseAll()));
Expand Down Expand Up @@ -428,7 +440,7 @@ const runKiroScript = Effect.fn("runKiroScript")(function* (input: {
createdBy: "user",
creationSource: "web",
messageId: MessageId.make(`message:${suffix}`),
text: "Say hello",
text: input.text ?? "Say hello",
attachments: [],
},
modelSelection: turnSelection,
Expand Down Expand Up @@ -831,6 +843,73 @@ describe("KiroAdapterV2", () => {
);
});

describe("KiroAdapterV2 skills", () => {
const userSkill = (name: string) => ({
name,
description: "A probe skill that checks skill discovery over ACP.",
input: { hint: "optional context" },
_meta: {
kiro: {
type: "skill",
resource: { resourceType: "skill", source: { origin: "user" } },
originalName: name,
},
},
});
// Shaped like kiro-cli 2.37.1's second `available_commands_update` after
// `session/new` with `~/.kiro/skills/probe-skill/SKILL.md` installed.
const skillCommands = update({
sessionUpdate: "available_commands_update",
availableCommands: [userSkill("probe-skill")],
});

const recordPrompts = (sent: Array<string>) =>
((send) => (payload, options) => {
for (const block of payload.prompt) {
if (block.type === "text") sent.push(block.text);
}
return send(payload, options);
}) satisfies NonNullable<Parameters<typeof runKiroScript>[0]["wrapPrompt"]>;

// The store holds the session's updates in order, so seeing `name` means every earlier one landed.
const awaitSkill = (skills: KiroSkills, name: string) =>
SubscriptionRef.changes(skills).pipe(
Stream.takeUntil((current) => current.some((skill) => skill.name === name)),
Stream.runDrain,
);

// Kiro runs a command only from a leading `/name`; T3's `$` picker inserts `$name`.
it.effect("sends a $ skill mention as the /command Kiro runs", () =>
Effect.gen(function* () {
const sent: Array<string> = [];
const skills = yield* makeKiroSkills();
yield* runKiroScript({
scenario: "skill-mention",
text: "$probe-skill rest",
skills,
frames: [
...openSessionFrames({
initialize: KIRO_V3_INITIALIZE,
configOptions: [modeOption, autopilotOption("on")],
}),
skillCommands,
turnPrompt,
answer("session/prompt", { stopReason: "end_turn" }),
...closeSession,
],
wrapPrompt: recordPrompts(sent),
drive: ({ events, startTurn }) =>
Effect.gen(function* () {
yield* awaitSkill(skills, "probe-skill");
yield* startTurn;
assert.equal(terminalStatus(yield* collectTurn(events)), "completed");
assert.deepEqual(sent, ["/probe-skill rest"]);
}),
});
}).pipe(Effect.provide(testLayer), Effect.scoped),
);
});

describe("KiroAdapterV2 agents", () => {
// A user agent from `~/.kiro/agents` whose config sets `"model": "claude-sonnet-4.5"`.
const agentModeOption = (currentValue: string) => ({
Expand Down
Loading
Loading