Skip to content

Commit 0e024bf

Browse files
hrishi maneclaude
andcommitted
feat(kiro): group subagent activity into Work-log entries
Kiro multiplexes `session/update` for the main session and every spawned subagent crew over one ACP channel, each tagged with its originating sessionId. Previously every subagent tool call, assistant message, and content delta landed on the main chat as flat inline rows — the "chaotic" UX relative to Claude Code's native Work-log grouping. Match Claude Code's behavior: subagent work appears as a collapsible Work-log group with live per-tool activity inside, and the main thread stays clean. - Thread `sessionId` through every `AcpParsedSessionEvent` variant and through `AcpAssistantSegmentState` in `AcpSessionRuntime`, so KiroAdapter can tell main-session events apart from subagent events. - Track in-flight subagents in `KiroSessionContext.subagentTasks`, keyed by ACP sessionId, and translate `_kiro.dev/subagent/list_update` roster transitions into `task.started` / `task.completed` envelopes. On session teardown, flush dangling tasks as `status: "stopped"` so the Work-log spinner doesn't hang. - Emit one `task.progress` per distinct subagent `toolCallId` (tracked per-subagent in `seenToolCallIds`) so the Work-log shows live activity inside the group while the crew is running, instead of sitting silent between start and end markers. - Format each progress row as `"{title}: {detail}"` via a new exported `formatSubagentToolLabel` helper so rows read "Read file: src/foo.ts" or "Ran command: bun test" instead of the bare category. - Drop subagent ContentDelta / AssistantItem / PlanUpdated / ModeChanged events from the main thread — they're subagent internals the user shouldn't see. The UI needs no changes — `deriveWorkLogEntries` already groups task.progress/task.completed under a collapsible Work-log widget. PATCH.md updated: new integration-points row for AcpRuntimeModel, bumped AcpSessionRuntime row, added a user-facing feature bullet, new row in the conflict-zones table, and a session-reflection entry. Tests: 18 new cases — 10 around roster parsing + diff semantics (missing sessionId, unknown status, started/completed emission, idempotence on re-sends, implicit termination on disappearance) and 8 on `formatSubagentToolLabel` (title+detail combining, command fallback, title-only, detail-only, identical dedup, kind fallback, empty "Working" fallback, whitespace trimming). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1012bdb commit 0e024bf

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)