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, {