From 0e024bf4bf91c9c5c496993fa00fcb41228472b4 Mon Sep 17 00:00:00 2001 From: hrishi mane Date: Mon, 20 Apr 2026 18:56:05 -0700 Subject: [PATCH] feat(kiro): group subagent activity into Work-log entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PATCH.md | 24 +- .../Layers/KiroAdapter.parsing.test.ts | 189 +++++++++++++- .../server/src/provider/Layers/KiroAdapter.ts | 247 +++++++++++++++++- .../src/provider/acp/AcpRuntimeModel.test.ts | 4 + .../src/provider/acp/AcpRuntimeModel.ts | 12 + .../src/provider/acp/AcpSessionRuntime.ts | 5 + 6 files changed, 469 insertions(+), 12 deletions(-) diff --git a/PATCH.md b/PATCH.md index 3b6af3a9181..7871b948c07 100644 --- a/PATCH.md +++ b/PATCH.md @@ -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) @@ -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/`): @@ -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 | @@ -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 diff --git a/apps/server/src/provider/Layers/KiroAdapter.parsing.test.ts b/apps/server/src/provider/Layers/KiroAdapter.parsing.test.ts index eddadb9c233..2a73c8c99fb 100644 --- a/apps/server/src/provider/Layers/KiroAdapter.parsing.test.ts +++ b/apps/server/src/provider/Layers/KiroAdapter.parsing.test.ts @@ -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", () => { @@ -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(), + }, + ], + ]); + + 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(), + }, + ], + ]); + 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", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/KiroAdapter.ts b/apps/server/src/provider/Layers/KiroAdapter.ts index 0df5519a043..bdc26a49ae0 100644 --- a/apps/server/src/provider/Layers/KiroAdapter.ts +++ b/apps/server/src/provider/Layers/KiroAdapter.ts @@ -13,6 +13,7 @@ import { type ProviderRuntimeEvent, type ProviderSession, RuntimeRequestId, + RuntimeTaskId, type ServerProviderSlashCommand, type ThreadId, TurnId, @@ -86,6 +87,23 @@ interface KiroSessionContext { // If a later sendTurn specifies a different agent, we must tear down this // session and respawn with the new agent before dispatching the turn. activeAgent: string | undefined; + // Tracks in-flight Kiro subagent sessions keyed by ACP sessionId. Each entry + // corresponds to one `task.started` we emitted — we use it to de-dup + // list_update notifications (which resend the full roster) and to emit + // `task.completed` exactly once when the subagent terminates or disappears. + readonly subagentTasks: Map; +} + +interface KiroSubagentTaskState { + readonly taskId: RuntimeTaskId; + readonly sessionName: string; + readonly agentName: string; + statusType: KiroSubagentStatusType; + // Set of toolCallIds we've already surfaced a `task.progress` for. Kiro + // emits `tool_call` + many `tool_call_update` notifications per call; we + // only need one progress pulse per tool invocation to show activity inside + // the Work-log group. + readonly seenToolCallIds: Set; } function settlePendingApprovalsAsCancelled( @@ -191,6 +209,118 @@ export function parseKiroPrompts(raw: ReadonlyArray): ServerProviderSla return commands; } +/** + * A single entry from the `_kiro.dev/subagent/list_update` roster, normalized + * into the fields we care about. Fields we don't read (`initialQuery`, + * `group`, `role`, `dependsOn`) are intentionally dropped. + */ +export interface KiroSubagentDescriptor { + readonly sessionId: string; + readonly sessionName: string; + readonly agentName: string; + readonly statusType: KiroSubagentStatusType; +} + +export type KiroSubagentStatusType = "working" | "terminated" | "unknown"; + +function normalizeKiroSubagentStatus(raw: unknown): KiroSubagentStatusType { + if (!isRecord(raw)) return "unknown"; + const type = typeof raw.type === "string" ? raw.type : ""; + if (type === "working") return "working"; + if (type === "terminated") return "terminated"; + return "unknown"; +} + +export function parseKiroSubagentList(raw: unknown): KiroSubagentDescriptor[] { + if (!isRecord(raw)) return []; + const list = raw.subagents; + if (!Array.isArray(list)) return []; + const out: KiroSubagentDescriptor[] = []; + for (const entry of list) { + if (!isRecord(entry)) continue; + const sessionId = typeof entry.sessionId === "string" ? entry.sessionId.trim() : ""; + if (!sessionId) continue; + const sessionName = + typeof entry.sessionName === "string" && entry.sessionName.trim().length > 0 + ? entry.sessionName.trim() + : sessionId; + const agentName = + typeof entry.agentName === "string" && entry.agentName.trim().length > 0 + ? entry.agentName.trim() + : "subagent"; + out.push({ + sessionId, + sessionName, + agentName, + statusType: normalizeKiroSubagentStatus(entry.status), + }); + } + return out; +} + +/** + * Build a concise, information-rich label for a subagent tool call, + * suitable for `task.progress.description`. Kiro tool calls carry + * presentation-normalized fields: `title` is a short category like + * "Ran command" / "Read file" / "Searched files", and `detail` carries + * the actual payload (the command, path, or query). The Work-log UI + * truncates long lines, so we return `"Title: detail"` when both are + * present so the user sees both what kind of action it is *and* the + * thing being acted on — matching Claude Code's subagent rows. + */ +export function formatSubagentToolLabel(toolCall: { + readonly title?: string; + readonly detail?: string; + readonly command?: string; + readonly kind?: string; +}): string { + const title = toolCall.title?.trim(); + const detail = toolCall.detail?.trim() || toolCall.command?.trim(); + if (title && detail && title !== detail) { + return `${title}: ${detail}`; + } + return title || detail || toolCall.kind?.trim() || "Working"; +} + +export type KiroSubagentRosterChange = + | { readonly kind: "started"; readonly descriptor: KiroSubagentDescriptor } + | { + readonly kind: "completed"; + readonly sessionId: string; + readonly prior: KiroSubagentTaskState; + }; + +/** + * Diff the incoming roster against the tracked set and produce the set of + * `task.started` / `task.completed` transitions we need to emit. A subagent + * that disappears from the roster (implicit termination) also counts as + * "completed". + */ +export function diffKiroSubagentRoster( + tracked: ReadonlyMap, + incoming: ReadonlyArray, +): KiroSubagentRosterChange[] { + const changes: KiroSubagentRosterChange[] = []; + const seen = new Set(); + for (const descriptor of incoming) { + seen.add(descriptor.sessionId); + const prior = tracked.get(descriptor.sessionId); + if (!prior && descriptor.statusType === "working") { + changes.push({ kind: "started", descriptor }); + continue; + } + if (prior && descriptor.statusType === "terminated" && prior.statusType !== "terminated") { + changes.push({ kind: "completed", sessionId: descriptor.sessionId, prior }); + } + } + for (const [sessionId, prior] of tracked) { + if (seen.has(sessionId)) continue; + if (prior.statusType === "terminated") continue; + changes.push({ kind: "completed", sessionId, prior }); + } + return changes; +} + function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { @@ -321,6 +451,25 @@ function makeKiroAdapter(options?: KiroAdapterLiveOptions) { if (ctx.stopped) return; ctx.stopped = true; yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + // Flush any still-open subagent task envelopes as stopped so the UI + // doesn't leave a dangling "Work log" spinner when the session tears + // down mid-crew. + for (const [, task] of ctx.subagentTasks) { + if (task.statusType === "terminated") continue; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), + payload: { + taskId: task.taskId, + status: "stopped", + }, + }); + } + ctx.subagentTasks.clear(); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } @@ -471,19 +620,65 @@ function makeKiroAdapter(options?: KiroAdapterLiveOptions) { }), ); - // Log Kiro subagent / MCP lifecycle notifications; we don't surface - // them as runtime events, but the native log captures the payloads - // for debugging and reproduction. + // Kiro subagent roster updates. Each descriptor represents one + // fan-out "crew" task. We translate roster transitions into + // `task.started` / `task.completed` runtime events so the UI's + // Work-log widget can collapse the subagent's tool calls under a + // single row — matching how Claude's native SDK exposes tasks. yield* acp.handleExtNotification( "_kiro.dev/subagent/list_update", Schema.Unknown, (params) => - logNative( - input.threadId, - "_kiro.dev/subagent/list_update", - params, - "acp.kiro.extension", - ), + Effect.gen(function* () { + yield* logNative( + input.threadId, + "_kiro.dev/subagent/list_update", + params, + "acp.kiro.extension", + ); + if (!ctx) return; + const incoming = parseKiroSubagentList(params); + const changes = diffKiroSubagentRoster(ctx.subagentTasks, incoming); + for (const change of changes) { + const stamp = yield* makeEventStamp(); + if (change.kind === "started") { + const { descriptor } = change; + const taskId = RuntimeTaskId.make(descriptor.sessionId); + ctx.subagentTasks.set(descriptor.sessionId, { + taskId, + sessionName: descriptor.sessionName, + agentName: descriptor.agentName, + statusType: descriptor.statusType, + seenToolCallIds: new Set(), + }); + yield* offerRuntimeEvent({ + type: "task.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), + payload: { + taskId, + description: descriptor.sessionName, + taskType: descriptor.agentName, + }, + }); + } else { + yield* offerRuntimeEvent({ + type: "task.completed", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), + payload: { + taskId: change.prior.taskId, + status: "completed", + }, + }); + ctx.subagentTasks.delete(change.sessionId); + } + } + }), ); yield* acp.handleExtNotification( @@ -621,11 +816,45 @@ function makeKiroAdapter(options?: KiroAdapterLiveOptions) { interrupted: false, mainSessionId: started.sessionId, activeAgent: kiroAgent, + subagentTasks: new Map(), }; const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + // Kiro emits session/update notifications tagged with the + // originating sessionId — main session and every spawned + // subagent crew share the same notification channel. + // + // For subagent events, we don't want to flood the main chat + // with the subagent's own assistant messages or raw tool + // boxes. Instead, we pulse a single `task.progress` per tool + // call so the UI shows live activity *inside* the Work-log + // group while the subagent is running. Everything else from + // the subagent (content deltas, assistant lifecycle, plans, + // mode changes) is dropped. + if (event.sessionId && event.sessionId !== ctx.mainSessionId) { + const task = ctx.subagentTasks.get(event.sessionId); + if (!task) return; + if (event._tag !== "ToolCallUpdated") return; + const toolCallId = event.toolCall.toolCallId; + if (task.seenToolCallIds.has(toolCallId)) return; + task.seenToolCallIds.add(toolCallId); + const label = formatSubagentToolLabel(event.toolCall); + yield* offerRuntimeEvent({ + type: "task.progress", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + ...(ctx.activeTurnId ? { turnId: ctx.activeTurnId } : {}), + payload: { + taskId: task.taskId, + description: label, + ...(event.toolCall.kind ? { lastToolName: event.toolCall.kind } : {}), + }, + }); + return; + } switch (event._tag) { case "ModeChanged": return; diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index ae12d3112aa..6462381d8c5 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -87,6 +87,7 @@ describe("AcpRuntimeModel", () => { expect(created.events).toEqual([ { _tag: "ToolCallUpdated", + sessionId: "session-1", toolCall: { toolCallId: "tool-1", kind: "execute", @@ -177,6 +178,7 @@ describe("AcpRuntimeModel", () => { expect(result.events).toEqual([ { _tag: "ModeChanged", + sessionId: "session-1", modeId: "code", }, ]); @@ -197,6 +199,7 @@ describe("AcpRuntimeModel", () => { expect(planResult.events).toEqual([ { _tag: "PlanUpdated", + sessionId: "session-1", payload: { plan: [ { step: "Inspect state", status: "completed" }, @@ -230,6 +233,7 @@ describe("AcpRuntimeModel", () => { expect(contentResult.events).toEqual([ { _tag: "ContentDelta", + sessionId: "session-1", text: "hello from acp", rawPayload: { sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index ffd214a5bf1..b96f3ff21be 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -44,28 +44,34 @@ export interface AcpPermissionRequest { export type AcpParsedSessionEvent = | { readonly _tag: "ModeChanged"; + readonly sessionId: string; readonly modeId: string; } | { readonly _tag: "AssistantItemStarted"; + readonly sessionId: string; readonly itemId: string; } | { readonly _tag: "AssistantItemCompleted"; + readonly sessionId: string; readonly itemId: string; } | { readonly _tag: "PlanUpdated"; + readonly sessionId: string; readonly payload: AcpPlanUpdate; readonly rawPayload: unknown; } | { readonly _tag: "ToolCallUpdated"; + readonly sessionId: string; readonly toolCall: AcpToolCallState; readonly rawPayload: unknown; } | { readonly _tag: "ContentDelta"; + readonly sessionId: string; readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; @@ -410,6 +416,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat readonly events: ReadonlyArray; } { const upd = params.update; + const sessionId = params.sessionId; const events: Array = []; let modeId: string | undefined; @@ -419,6 +426,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat if (modeId) { events.push({ _tag: "ModeChanged", + sessionId, modeId, }); } @@ -432,6 +440,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat if (plan.length > 0) { events.push({ _tag: "PlanUpdated", + sessionId, payload: { plan, }, @@ -447,6 +456,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat if (toolCall) { events.push({ _tag: "ToolCallUpdated", + sessionId, toolCall, rawPayload: params, }); @@ -458,6 +468,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat if (toolCall) { events.push({ _tag: "ToolCallUpdated", + sessionId, toolCall, rawPayload: params, }); @@ -468,6 +479,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat if (upd.content.type === "text" && upd.content.text.length > 0) { events.push({ _tag: "ContentDelta", + sessionId, text: upd.content.text, rawPayload: params, }); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 0225efe9917..e0287aa5b3d 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -115,6 +115,7 @@ type AcpStartState = interface AcpAssistantSegmentState { readonly nextSegmentIndex: number; readonly activeItemId?: string; + readonly activeSessionId?: string; } interface EnsureActiveAssistantSegmentResult { @@ -607,6 +608,7 @@ const handleSessionUpdate = ({ } yield* Queue.offer(queue, { _tag: "ToolCallUpdated", + sessionId: event.sessionId, toolCall: merged, rawPayload: event.rawPayload, }); @@ -684,12 +686,14 @@ const ensureActiveAssistantSegment = ({ itemId, startedEvent: { _tag: "AssistantItemStarted", + sessionId, itemId, } satisfies Extract, }, { nextSegmentIndex: current.nextSegmentIndex + 1, activeItemId: itemId, + activeSessionId: sessionId, } satisfies AcpAssistantSegmentState, ] as const; }, @@ -715,6 +719,7 @@ const closeActiveAssistantSegment = ({ return [ { _tag: "AssistantItemCompleted", + sessionId: current.activeSessionId ?? "", itemId: current.activeItemId, } satisfies AcpParsedSessionEvent, {