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
189 changes: 188 additions & 1 deletion apps/server/src/provider/Layers/KiroAdapter.parsing.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";

import { parseKiroPrompts, parseKiroSlashCommands } from "./KiroAdapter.ts";
import { RuntimeTaskId } from "@t3tools/contracts";

import {
diffKiroSubagentRoster,
formatSubagentToolLabel,
parseKiroPrompts,
parseKiroSlashCommands,
parseKiroSubagentList,
} from "./KiroAdapter.ts";
import { parseKiroAgentListOutput } from "./KiroProvider.ts";

describe("parseKiroSlashCommands", () => {
Expand Down Expand Up @@ -164,3 +172,182 @@ describe("parseKiroAgentListOutput", () => {
expect(agents[0]!.description).toBeUndefined();
});
});

describe("parseKiroSubagentList", () => {
it("extracts sessionId, names, and status", () => {
const result = parseKiroSubagentList({
subagents: [
{
sessionId: "s1",
sessionName: "sdk-serialization",
agentName: "codebase-explorer",
status: { type: "working", message: "Running" },
group: "crew-1",
role: "codebase-explorer",
dependsOn: [],
},
{
sessionId: "s2",
sessionName: "cli-serialization",
agentName: "codebase-explorer",
status: { type: "terminated" },
},
],
pendingStages: [],
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
sessionId: "s1",
sessionName: "sdk-serialization",
agentName: "codebase-explorer",
statusType: "working",
});
expect(result[1]!.statusType).toBe("terminated");
});

it("falls back to sessionId when sessionName is missing", () => {
const result = parseKiroSubagentList({
subagents: [{ sessionId: "abc", status: { type: "working" } }],
});
expect(result[0]!.sessionName).toBe("abc");
expect(result[0]!.agentName).toBe("subagent");
});

it("treats unknown status shapes as 'unknown'", () => {
const result = parseKiroSubagentList({
subagents: [{ sessionId: "x", status: { type: "mystery" } }],
});
expect(result[0]!.statusType).toBe("unknown");
});

it("skips entries missing sessionId", () => {
const result = parseKiroSubagentList({
subagents: [{ sessionName: "no-id", status: { type: "working" } }, null, "string"],
});
expect(result).toHaveLength(0);
});

it("returns [] for non-object input", () => {
expect(parseKiroSubagentList(null)).toEqual([]);
expect(parseKiroSubagentList({ subagents: "nope" })).toEqual([]);
});
});

describe("diffKiroSubagentRoster", () => {
const trackedWorking = () =>
new Map([
[
"s1",
{
taskId: RuntimeTaskId.make("s1"),
sessionName: "sdk-serialization",
agentName: "codebase-explorer",
statusType: "working" as const,
seenToolCallIds: new Set<string>(),
},
],
]);

it("emits 'started' for a new working entry", () => {
const changes = diffKiroSubagentRoster(new Map(), [
{
sessionId: "s1",
sessionName: "sdk",
agentName: "codebase-explorer",
statusType: "working",
},
]);
expect(changes).toHaveLength(1);
expect(changes[0]!.kind).toBe("started");
});

it("emits 'completed' when a tracked entry transitions to terminated", () => {
const changes = diffKiroSubagentRoster(trackedWorking(), [
{ sessionId: "s1", sessionName: "sdk", agentName: "x", statusType: "terminated" },
]);
expect(changes).toHaveLength(1);
expect(changes[0]!.kind).toBe("completed");
});

it("emits 'completed' when a tracked entry disappears from the roster", () => {
const changes = diffKiroSubagentRoster(trackedWorking(), []);
expect(changes).toHaveLength(1);
expect(changes[0]!.kind).toBe("completed");
});

it("does not re-emit 'started' for already-tracked entries", () => {
const changes = diffKiroSubagentRoster(trackedWorking(), [
{ sessionId: "s1", sessionName: "sdk", agentName: "x", statusType: "working" },
]);
expect(changes).toHaveLength(0);
});

it("does not re-emit 'completed' for already-terminated entries", () => {
const tracked = new Map([
[
"s1",
{
taskId: RuntimeTaskId.make("s1"),
sessionName: "sdk",
agentName: "x",
statusType: "terminated" as const,
seenToolCallIds: new Set<string>(),
},
],
]);
const changes = diffKiroSubagentRoster(tracked, [
{ sessionId: "s1", sessionName: "sdk", agentName: "x", statusType: "terminated" },
]);
expect(changes).toHaveLength(0);
});
});

describe("formatSubagentToolLabel", () => {
it("combines presentation title with its payload detail", () => {
expect(formatSubagentToolLabel({ title: "Ran command", detail: "bun test" })).toBe(
"Ran command: bun test",
);
expect(formatSubagentToolLabel({ title: "Read file", detail: "src/foo.ts" })).toBe(
"Read file: src/foo.ts",
);
expect(formatSubagentToolLabel({ title: "Searched files", detail: "useState" })).toBe(
"Searched files: useState",
);
});

it("falls back to command when detail is missing", () => {
expect(formatSubagentToolLabel({ title: "Ran command", command: "ls -la" })).toBe(
"Ran command: ls -la",
);
});

it("returns the title alone when no detail is available", () => {
expect(formatSubagentToolLabel({ title: "Summarizing" })).toBe("Summarizing");
});

it("returns the detail alone when no title is available", () => {
expect(formatSubagentToolLabel({ detail: "apps/server/foo.ts" })).toBe(
"apps/server/foo.ts",
);
});

it("does not duplicate when title and detail are identical", () => {
expect(formatSubagentToolLabel({ title: "Summarizing", detail: "Summarizing" })).toBe(
"Summarizing",
);
});

it("falls through to kind when everything else is empty", () => {
expect(formatSubagentToolLabel({ kind: "execute" })).toBe("execute");
});

it("returns 'Working' as a last-resort fallback", () => {
expect(formatSubagentToolLabel({})).toBe("Working");
});

it("trims whitespace on all inputs", () => {
expect(formatSubagentToolLabel({ title: " Ran command ", detail: " ls " })).toBe(
"Ran command: ls",
);
});
});
Loading
Loading