Skip to content
Merged
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
24 changes: 22 additions & 2 deletions PATCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Kiro as a first-class ACP provider, layered on top of upstream's shared ACP infr
- Dynamic slash commands from `_kiro.dev/commands/available` notifications
- Context window usage from `_kiro.dev/metadata` notifications
- Agent selection is persisted per-thread in the composer draft store
- Subagent crews fan out into collapsible Work-log groups: `_kiro.dev/subagent/list_update` roster transitions become `task.started` / `task.completed` envelopes keyed by the crew's ACP sessionId; per-tool activity inside each crew is pulsed as `task.progress` rows (one per distinct `toolCallId`) with labels formatted as `"{title}: {detail}"` (e.g. `Read file: src/foo.ts`, `Ran command: bun test`). Subagent ContentDelta / AssistantItem / PlanUpdated / ModeChanged events are dropped from the main thread — matching Claude Code's SDK behavior of hiding subagent internals behind task notifications.

### Server-only additions (new files)

Expand Down Expand Up @@ -164,7 +165,8 @@ Every shared-file edit is a _pure addition_ (new case in a union, new entry in a
| `provider/Layers/ProviderAdapterRegistry.ts` | Register `KiroAdapter` |
| `provider/providerStatusCache.ts` | Add `"kiro"` to `PROVIDER_CACHE_IDS` |
| `provider/makeManagedServerProvider.ts` | Add `patchSnapshot` (additive, does not replace `enrichSnapshot`) |
| `provider/acp/AcpSessionRuntime.ts` | `authMethodId` made optional (Kiro uses OIDC, skips authenticate) |
| `provider/acp/AcpSessionRuntime.ts` | `authMethodId` optional (Kiro uses OIDC); thread `sessionId` through `AssistantSegmentState` + re-emitted `ToolCallUpdated` so subagent events can be filtered out of the main thread |
| `provider/acp/AcpRuntimeModel.ts` | `AcpParsedSessionEvent` union gains `sessionId` on every variant so downstream consumers can tell main-session vs subagent events apart |
| `git/Services/TextGeneration.ts` | Handle kiro provider kind |

**Web** (`apps/web/src/`):
Expand Down Expand Up @@ -247,7 +249,8 @@ Upstream's `makeManagedServerProvider` exposes `enrichSnapshot` for static provi
| `apps/server/src/server.ts` | RuntimeServicesLive layer chain |
| `apps/server/src/provider/Layers/ProviderRegistry.ts` | Provider registration list |
| `apps/server/src/provider/providerStatusCache.ts` | `PROVIDER_CACHE_IDS` array |
| `apps/server/src/provider/acp/AcpSessionRuntime.ts` | Optional `authMethodId` |
| `apps/server/src/provider/acp/AcpSessionRuntime.ts` | Optional `authMethodId`; `sessionId` plumbing for subagent filtering |
| `apps/server/src/provider/acp/AcpRuntimeModel.ts` | `sessionId` on `AcpParsedSessionEvent` variants |
| `apps/server/src/provider/makeManagedServerProvider.ts` | Added `patchSnapshot` |
| `apps/web/src/composerDraftStore.ts` | Three provider-kind lists |
| `apps/web/src/components/settings/SettingsPanels.tsx` | Provider panel registration |
Expand Down Expand Up @@ -311,6 +314,23 @@ Fork runs in lockstep with upstream (currently Effect v4 beta.45+). If you see t

- Refactor to a single `PROVIDER_KINDS` const tuple exported from contracts to eliminate the three-list hazard permanently (also makes `normalizeProviderModelOptionsWithCapabilities` exhaustiveness-check at compile time).

## Session Reflections (2026-04-20 — round 3: subagent grouping)

### What broke and what fixed it

**Bug: Kiro subagent crews flooded the main chat — one flat stream of "Read file" / "Ran command" / assistant-message rows per subagent, no grouping.**

- Kiro's ACP transport multiplexes `session/update` for the main session *and* every spawned subagent crew over one channel, tagged with `sessionId`. Upstream's `AcpRuntimeModel.parseSessionUpdateEvent` dropped that `sessionId` before reaching the adapter, so everything looked like main-session activity.
- Fix landed in three passes:
1. Thread `sessionId` through `AcpParsedSessionEvent` and `AcpSessionRuntime`, track a roster of in-flight subagents by their ACP sessionId, and translate `_kiro.dev/subagent/list_update` transitions into `task.started` / `task.completed` envelopes. Subagent session/update events are dropped on the main thread.
2. Dropping *everything* from subagents made the Work-log look stuck — the group sat silent until the crew terminated. Emit one `task.progress` per distinct subagent `toolCallId` (tracked per-subagent in `seenToolCallIds`) so the Work-log shows live per-tool activity inside the collapsible group, matching Claude Code's native SDK behavior.
3. Work-log rows initially rendered only the generic category ("Ran command", "Read file"). Kiro's typed tool-call presentation puts the action in `title` and the payload in `detail`; combine them as `"{title}: {detail}"` via a new `formatSubagentToolLabel` helper so rows show the actual command / path / query. 8 unit tests cover the helper.

### Lessons worth keeping

1. **Multiplexed channels need identity on every event.** Any time a transport fans in multiple logical streams, the parser must preserve the stream identity all the way to the consumer. Dropping it at the parser layer is irreversible downstream.
2. **"Don't route" is not the same as "don't show".** The first pass filtered subagent events out of main-thread routing entirely; the fix was to keep a single summarized breadcrumb (task.progress per tool call) so the user sees progress without being drowned in subagent internals.

## Session Reflections (2026-04-20 — round 2)

### What broke and what fixed it
Expand Down
9 changes: 9 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,15 @@ const program = Effect.gen(function* () {
),
);

yield* agent.handleSetSessionModel((request) =>
Effect.gen(function* () {
if (typeof request.modelId === "string") {
currentModelId = request.modelId;
}
return {};
}),
);

yield* agent.handleSetSessionConfigOption((request) =>
Effect.gen(function* () {
if (exitOnSetConfigOption) {
Expand Down
38 changes: 37 additions & 1 deletion apps/server/scripts/kiro-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
/**
* Mock ACP agent that imitates kiro-cli ACP protocol for integration tests.
*
* Responds to: initialize, session/new, session/prompt, session/cancel.
* Responds to: initialize, session/new, session/prompt, session/cancel,
* session/set_model, session/set_config_option.
* Emits: session/update (agent_message_chunk), _kiro.dev/metadata extension.
*/
import * as Effect from "effect/Effect";
Expand All @@ -14,6 +15,22 @@ import * as EffectAcpAgent from "effect-acp/agent";

const sessionId = "kiro-mock-session-1";

/** Available models — returned in session responses. */
const availableModels = ["auto", "claude-sonnet-4-20250514", "claude-opus-4-20250918"];
let currentModel = "auto";

/** Build the model config option structure used in ACP responses. */
function makeModelConfigOption(model: string) {
return {
id: "model",
name: "Model",
type: "select" as const,
category: "model" as const,
currentValue: model,
options: availableModels.map((m) => ({ value: m, name: m })),
};
}

const program = Effect.gen(function* () {
const agent = yield* EffectAcpAgent.AcpAgent;

Expand All @@ -33,13 +50,32 @@ const program = Effect.gen(function* () {
yield* agent.handleCreateSession(() =>
Effect.succeed({
sessionId,
configOptions: [makeModelConfigOption(currentModel)],
}),
);

yield* agent.handleLoadSession(() => Effect.succeed({}));

yield* agent.handleCancel(() => Effect.void);

yield* agent.handleSetSessionModel((request) =>
Effect.gen(function* () {
if (typeof request.modelId === "string") {
currentModel = request.modelId;
}
return {};
}),
);

yield* agent.handleSetSessionConfigOption((request) =>
Effect.gen(function* () {
if (request.configId === "model" && "value" in request && typeof request.value === "string") {
currentModel = request.value;
}
return { configOptions: [makeModelConfigOption(currentModel)] };
}),
);

yield* agent.handlePrompt((request) =>
Effect.gen(function* () {
const requestedSessionId = String(request.sessionId ?? sessionId);
Expand Down
137 changes: 137 additions & 0 deletions apps/server/src/provider/Layers/KiroAdapter.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,141 @@ describe("KiroAdapterLive integration", () => {
yield* adapter.stopSession(threadId);
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
);

it.effect(
"switches model in-session without restarting the process",
() =>
Effect.gen(function* () {
const adapter = yield* KiroAdapter;
const threadId = ThreadId.make("kiro-int-model-switch-1");

yield* adapter.startSession({
threadId,
provider: "kiro",
cwd: process.cwd(),
runtimeMode: "full-access",
modelSelection: { provider: "kiro", model: "auto" },
});

const spawnCountAfterStart = capturedArgs.length;

// First turn with default model
yield* adapter.sendTurn({
threadId,
input: "first turn",
attachments: [],
});

// Second turn with different model — should NOT respawn
yield* adapter.sendTurn({
threadId,
input: "second turn after model switch",
attachments: [],
modelSelection: {
provider: "kiro",
model: "claude-sonnet-4-20250514",
},
});

// Session should NOT have been restarted — only one spawn
expect(capturedArgs.length).toBe(spawnCountAfterStart);

yield* adapter.stopSession(threadId);
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
);

it.effect(
"updates session.model immediately after in-session model switch",
() =>
Effect.gen(function* () {
const adapter = yield* KiroAdapter;
const threadId = ThreadId.make("kiro-int-model-state-1");

const session = yield* adapter.startSession({
threadId,
provider: "kiro",
cwd: process.cwd(),
runtimeMode: "full-access",
modelSelection: { provider: "kiro", model: "auto" },
});

expect(session.model).toBe("auto");

// Collect turn.started events to verify model is correct
const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter(
(event) => event.type === "turn.started" || event.type === "turn.completed",
),
Stream.take(2), // turn.started + turn.completed for the model-switch turn
Stream.runCollect,
Effect.forkChild,
);

// Send turn with new model
const turn = yield* adapter.sendTurn({
threadId,
input: "switch model turn",
attachments: [],
modelSelection: {
provider: "kiro",
model: "claude-sonnet-4-20250514",
},
});

expect(turn.threadId).toBe(threadId);

const events = yield* Fiber.join(eventsFiber);
const turnStarted = events.find((e) => e.type === "turn.started");
expect(turnStarted).toBeDefined();
if (turnStarted?.type === "turn.started") {
expect(turnStarted.payload.model).toBe("claude-sonnet-4-20250514");
}

// Verify session state reflects the new model
const sessions = yield* adapter.listSessions();
const currentSession = sessions.find((s) => s.threadId === threadId);
expect(currentSession?.model).toBe("claude-sonnet-4-20250514");

yield* adapter.stopSession(threadId);
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
);

it.effect(
"does not call setModel when model is unchanged between turns",
() =>
Effect.gen(function* () {
const adapter = yield* KiroAdapter;
const threadId = ThreadId.make("kiro-int-model-unchanged-1");

yield* adapter.startSession({
threadId,
provider: "kiro",
cwd: process.cwd(),
runtimeMode: "full-access",
modelSelection: { provider: "kiro", model: "auto" },
});

// Two turns with the same model — should not trigger setModel
yield* adapter.sendTurn({
threadId,
input: "first turn",
attachments: [],
modelSelection: { provider: "kiro", model: "auto" },
});

yield* adapter.sendTurn({
threadId,
input: "second turn same model",
attachments: [],
modelSelection: { provider: "kiro", model: "auto" },
});

// Both turns should succeed without issues (no setModel called)
const sessions = yield* adapter.listSessions();
const currentSession = sessions.find((s) => s.threadId === threadId);
expect(currentSession?.model).toBe("auto");

yield* adapter.stopSession(threadId);
}).pipe(Effect.scoped, Effect.provide(adapterLayer)),
);
});
Loading
Loading