Skip to content

Commit 6e1d670

Browse files
Merge pull request #2 from hrishikeshmane/kiro-subagent-grouping
feat(kiro): group subagent activity into Work-log entries
2 parents 1012bdb + 0e024bf commit 6e1d670

6 files changed

Lines changed: 469 additions & 12 deletions

File tree

PATCH.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ Kiro as a first-class ACP provider, layered on top of upstream's shared ACP infr
123123
- Dynamic slash commands from `_kiro.dev/commands/available` notifications
124124
- Context window usage from `_kiro.dev/metadata` notifications
125125
- Agent selection is persisted per-thread in the composer draft store
126+
- 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.
126127

127128
### Server-only additions (new files)
128129

@@ -164,7 +165,8 @@ Every shared-file edit is a _pure addition_ (new case in a union, new entry in a
164165
| `provider/Layers/ProviderAdapterRegistry.ts` | Register `KiroAdapter` |
165166
| `provider/providerStatusCache.ts` | Add `"kiro"` to `PROVIDER_CACHE_IDS` |
166167
| `provider/makeManagedServerProvider.ts` | Add `patchSnapshot` (additive, does not replace `enrichSnapshot`) |
167-
| `provider/acp/AcpSessionRuntime.ts` | `authMethodId` made optional (Kiro uses OIDC, skips authenticate) |
168+
| `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 |
169+
| `provider/acp/AcpRuntimeModel.ts` | `AcpParsedSessionEvent` union gains `sessionId` on every variant so downstream consumers can tell main-session vs subagent events apart |
168170
| `git/Services/TextGeneration.ts` | Handle kiro provider kind |
169171

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

312315
- 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).
313316

317+
## Session Reflections (2026-04-20 — round 3: subagent grouping)
318+
319+
### What broke and what fixed it
320+
321+
**Bug: Kiro subagent crews flooded the main chat — one flat stream of "Read file" / "Ran command" / assistant-message rows per subagent, no grouping.**
322+
323+
- 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.
324+
- Fix landed in three passes:
325+
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.
326+
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.
327+
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.
328+
329+
### Lessons worth keeping
330+
331+
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.
332+
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.
333+
314334
## Session Reflections (2026-04-20 — round 2)
315335

316336
### What broke and what fixed it

apps/server/src/provider/Layers/KiroAdapter.parsing.test.ts

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { parseKiroPrompts, parseKiroSlashCommands } from "./KiroAdapter.ts";
3+
import { RuntimeTaskId } from "@t3tools/contracts";
4+
5+
import {
6+
diffKiroSubagentRoster,
7+
formatSubagentToolLabel,
8+
parseKiroPrompts,
9+
parseKiroSlashCommands,
10+
parseKiroSubagentList,
11+
} from "./KiroAdapter.ts";
412
import { parseKiroAgentListOutput } from "./KiroProvider.ts";
513

614
describe("parseKiroSlashCommands", () => {
@@ -164,3 +172,182 @@ describe("parseKiroAgentListOutput", () => {
164172
expect(agents[0]!.description).toBeUndefined();
165173
});
166174
});
175+
176+
describe("parseKiroSubagentList", () => {
177+
it("extracts sessionId, names, and status", () => {
178+
const result = parseKiroSubagentList({
179+
subagents: [
180+
{
181+
sessionId: "s1",
182+
sessionName: "sdk-serialization",
183+
agentName: "codebase-explorer",
184+
status: { type: "working", message: "Running" },
185+
group: "crew-1",
186+
role: "codebase-explorer",
187+
dependsOn: [],
188+
},
189+
{
190+
sessionId: "s2",
191+
sessionName: "cli-serialization",
192+
agentName: "codebase-explorer",
193+
status: { type: "terminated" },
194+
},
195+
],
196+
pendingStages: [],
197+
});
198+
expect(result).toHaveLength(2);
199+
expect(result[0]).toEqual({
200+
sessionId: "s1",
201+
sessionName: "sdk-serialization",
202+
agentName: "codebase-explorer",
203+
statusType: "working",
204+
});
205+
expect(result[1]!.statusType).toBe("terminated");
206+
});
207+
208+
it("falls back to sessionId when sessionName is missing", () => {
209+
const result = parseKiroSubagentList({
210+
subagents: [{ sessionId: "abc", status: { type: "working" } }],
211+
});
212+
expect(result[0]!.sessionName).toBe("abc");
213+
expect(result[0]!.agentName).toBe("subagent");
214+
});
215+
216+
it("treats unknown status shapes as 'unknown'", () => {
217+
const result = parseKiroSubagentList({
218+
subagents: [{ sessionId: "x", status: { type: "mystery" } }],
219+
});
220+
expect(result[0]!.statusType).toBe("unknown");
221+
});
222+
223+
it("skips entries missing sessionId", () => {
224+
const result = parseKiroSubagentList({
225+
subagents: [{ sessionName: "no-id", status: { type: "working" } }, null, "string"],
226+
});
227+
expect(result).toHaveLength(0);
228+
});
229+
230+
it("returns [] for non-object input", () => {
231+
expect(parseKiroSubagentList(null)).toEqual([]);
232+
expect(parseKiroSubagentList({ subagents: "nope" })).toEqual([]);
233+
});
234+
});
235+
236+
describe("diffKiroSubagentRoster", () => {
237+
const trackedWorking = () =>
238+
new Map([
239+
[
240+
"s1",
241+
{
242+
taskId: RuntimeTaskId.make("s1"),
243+
sessionName: "sdk-serialization",
244+
agentName: "codebase-explorer",
245+
statusType: "working" as const,
246+
seenToolCallIds: new Set<string>(),
247+
},
248+
],
249+
]);
250+
251+
it("emits 'started' for a new working entry", () => {
252+
const changes = diffKiroSubagentRoster(new Map(), [
253+
{
254+
sessionId: "s1",
255+
sessionName: "sdk",
256+
agentName: "codebase-explorer",
257+
statusType: "working",
258+
},
259+
]);
260+
expect(changes).toHaveLength(1);
261+
expect(changes[0]!.kind).toBe("started");
262+
});
263+
264+
it("emits 'completed' when a tracked entry transitions to terminated", () => {
265+
const changes = diffKiroSubagentRoster(trackedWorking(), [
266+
{ sessionId: "s1", sessionName: "sdk", agentName: "x", statusType: "terminated" },
267+
]);
268+
expect(changes).toHaveLength(1);
269+
expect(changes[0]!.kind).toBe("completed");
270+
});
271+
272+
it("emits 'completed' when a tracked entry disappears from the roster", () => {
273+
const changes = diffKiroSubagentRoster(trackedWorking(), []);
274+
expect(changes).toHaveLength(1);
275+
expect(changes[0]!.kind).toBe("completed");
276+
});
277+
278+
it("does not re-emit 'started' for already-tracked entries", () => {
279+
const changes = diffKiroSubagentRoster(trackedWorking(), [
280+
{ sessionId: "s1", sessionName: "sdk", agentName: "x", statusType: "working" },
281+
]);
282+
expect(changes).toHaveLength(0);
283+
});
284+
285+
it("does not re-emit 'completed' for already-terminated entries", () => {
286+
const tracked = new Map([
287+
[
288+
"s1",
289+
{
290+
taskId: RuntimeTaskId.make("s1"),
291+
sessionName: "sdk",
292+
agentName: "x",
293+
statusType: "terminated" as const,
294+
seenToolCallIds: new Set<string>(),
295+
},
296+
],
297+
]);
298+
const changes = diffKiroSubagentRoster(tracked, [
299+
{ sessionId: "s1", sessionName: "sdk", agentName: "x", statusType: "terminated" },
300+
]);
301+
expect(changes).toHaveLength(0);
302+
});
303+
});
304+
305+
describe("formatSubagentToolLabel", () => {
306+
it("combines presentation title with its payload detail", () => {
307+
expect(formatSubagentToolLabel({ title: "Ran command", detail: "bun test" })).toBe(
308+
"Ran command: bun test",
309+
);
310+
expect(formatSubagentToolLabel({ title: "Read file", detail: "src/foo.ts" })).toBe(
311+
"Read file: src/foo.ts",
312+
);
313+
expect(formatSubagentToolLabel({ title: "Searched files", detail: "useState" })).toBe(
314+
"Searched files: useState",
315+
);
316+
});
317+
318+
it("falls back to command when detail is missing", () => {
319+
expect(formatSubagentToolLabel({ title: "Ran command", command: "ls -la" })).toBe(
320+
"Ran command: ls -la",
321+
);
322+
});
323+
324+
it("returns the title alone when no detail is available", () => {
325+
expect(formatSubagentToolLabel({ title: "Summarizing" })).toBe("Summarizing");
326+
});
327+
328+
it("returns the detail alone when no title is available", () => {
329+
expect(formatSubagentToolLabel({ detail: "apps/server/foo.ts" })).toBe(
330+
"apps/server/foo.ts",
331+
);
332+
});
333+
334+
it("does not duplicate when title and detail are identical", () => {
335+
expect(formatSubagentToolLabel({ title: "Summarizing", detail: "Summarizing" })).toBe(
336+
"Summarizing",
337+
);
338+
});
339+
340+
it("falls through to kind when everything else is empty", () => {
341+
expect(formatSubagentToolLabel({ kind: "execute" })).toBe("execute");
342+
});
343+
344+
it("returns 'Working' as a last-resort fallback", () => {
345+
expect(formatSubagentToolLabel({})).toBe("Working");
346+
});
347+
348+
it("trims whitespace on all inputs", () => {
349+
expect(formatSubagentToolLabel({ title: " Ran command ", detail: " ls " })).toBe(
350+
"Ran command: ls",
351+
);
352+
});
353+
});

0 commit comments

Comments
 (0)