From ef5d1d803d95c8a1410f01ed0bdf345cc2743c10 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 17:45:01 +0900 Subject: [PATCH] feat(chat): show live subagent activity summaries --- .../app-server/inbound/notification-plan.ts | 181 +++++++++++++- .../inbound/notification-routing.ts | 6 +- .../chat/application/state/root-reducer.ts | 20 ++ .../application/state/subagent-activity.ts | 234 ++++++++++++++++++ .../thread-stream/semantics/active-turn.ts | 45 +++- src/features/chat/panel/shell-selectors.ts | 2 + .../panel/surface/thread-stream-projection.ts | 11 + .../presentation/thread-stream/status-view.ts | 2 +- .../subagent-activity-preview.ts | 51 ++++ .../presentation/thread-stream/view-model.ts | 7 +- src/features/chat/ui/thread-stream/status.tsx | 8 +- src/styles/24-chat-stream-items.css | 4 +- .../chat/app-server/inbound/handler.test.ts | 158 ++++++++++++ .../application/state/root-reducer.test.ts | 6 + .../state/subagent-activity.test.ts | 136 ++++++++++ .../semantics/active-turn.test.ts | 107 +++++++- .../chat/panel/surface/projections.test.ts | 53 +++- .../thread-stream/status-view.test.ts | 28 +++ .../subagent-activity-preview.test.ts | 68 +++++ .../ui/thread-stream/stream-items.test.tsx | 4 +- 20 files changed, 1106 insertions(+), 25 deletions(-) create mode 100644 src/features/chat/application/state/subagent-activity.ts create mode 100644 src/features/chat/presentation/thread-stream/subagent-activity-preview.ts create mode 100644 tests/features/chat/application/state/subagent-activity.test.ts create mode 100644 tests/features/chat/presentation/thread-stream/status-view.test.ts create mode 100644 tests/features/chat/presentation/thread-stream/subagent-activity-preview.test.ts diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index be52a5f7..4962d134 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -6,7 +6,9 @@ import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions"; import { activeThreadSettingsAppliedAction } from "../../application/state/actions"; import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer"; +import type { SubagentActivityAction } from "../../application/state/subagent-activity"; import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan"; +import type { TurnRuntimeEvent } from "../../application/turns/runtime-events"; import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items"; import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing"; import { turnRuntimeEventsFromNotification } from "./runtime-events"; @@ -45,6 +47,7 @@ export function planChatNotification( }); switch (route.kind) { case "inactive": + return planTrackedSubagentNotification(state, route.scope.threadId, route.notification, localItemId); case "ignored": case "unhandled": return EMPTY_PLAN; @@ -65,13 +68,178 @@ function runtimeEventsPlan( notification: Parameters[0], localItemId: LocalItemIdProvider, ): ChatNotificationPlan { - const plan = planTurnRuntimeEvents(state, turnRuntimeEventsFromNotification(notification, localItemId)); + const events = turnRuntimeEventsFromNotification(notification, localItemId); + const plan = planTurnRuntimeEvents(state, events); return { - actions: plan.actions, + actions: [ + ...plan.actions, + ...subagentTrackingActionsFromParentEvents(state, events), + ...subagentTrackingActionsFromActivityItem(state, notification), + ], effects: plan.outcomes.flatMap((outcome) => chatNotificationEffectsFromTurnRuntimeOutcome(state, outcome)), }; } +function subagentTrackingActionsFromActivityItem( + state: ChatState, + notification: Parameters[0], +): SubagentActivityAction[] { + if (notification.method !== "item/started" && notification.method !== "item/completed") return []; + const item = notification.params.item; + if (item.type !== "subAgentActivity") return []; + const parentTurnId = activeTurnIdForState(state); + if (!parentTurnId || notification.params.turnId !== parentTurnId) return []; + const tracked: SubagentActivityAction = { + type: "subagent-activity/tracked", + threadId: item.agentThreadId, + parentTurnId, + }; + return item.kind === "interrupted" + ? [tracked, { type: "subagent-activity/execution-state-changed", threadId: item.agentThreadId, executionState: "failed" }] + : [tracked]; +} + +function planTrackedSubagentNotification( + state: ChatState, + threadId: string | null, + notification: ServerNotification, + localItemId: LocalItemIdProvider, +): ChatNotificationPlan { + if (!threadId || !state.subagentActivity.byThreadId.has(threadId)) return EMPTY_PLAN; + const events = subagentRuntimeEvents(notification, localItemId); + if (!events) return EMPTY_PLAN; + const actions = events.flatMap((event) => subagentActivityActionsFromRuntimeEvent(threadId, notification.method, event)); + return actions.length > 0 ? { actions, effects: [] } : EMPTY_PLAN; +} + +function subagentRuntimeEvents(notification: ServerNotification, localItemId: LocalItemIdProvider): readonly TurnRuntimeEvent[] | null { + switch (notification.method) { + case "item/agentMessage/delta": + case "item/plan/delta": + case "turn/plan/updated": + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": + case "item/reasoning/summaryPartAdded": + case "item/started": + case "item/completed": + case "item/commandExecution/outputDelta": + case "item/fileChange/patchUpdated": + case "turn/diff/updated": + case "hook/started": + case "hook/completed": + case "item/mcpToolCall/progress": + case "item/autoApprovalReview/started": + case "item/autoApprovalReview/completed": + case "guardianWarning": + case "turn/started": + case "turn/completed": + return turnRuntimeEventsFromNotification(notification, localItemId); + default: + return null; + } +} + +function subagentActivityActionsFromRuntimeEvent( + threadId: string, + notificationMethod: ServerNotification["method"], + event: TurnRuntimeEvent, +): SubagentActivityAction[] { + switch (event.type) { + case "turnStarted": + return [{ type: "subagent-activity/turn-started", threadId, childTurnId: event.turnId }]; + case "turnCompleted": + return [ + { + type: "subagent-activity/turn-completed", + threadId, + childTurnId: event.turnId, + items: event.completedItems, + executionState: event.status === "completed" ? "completed" : "failed", + }, + ]; + case "itemUpserted": + return [ + { + type: "subagent-activity/item-observed", + threadId, + item: event.item, + advance: notificationMethod === "item/started" || notificationMethod === "turn/plan/updated", + }, + ]; + case "itemCompleted": + return [{ type: "subagent-activity/item-observed", threadId, item: event.item, advance: false }]; + case "assistantDelta": + return [ + { + type: "subagent-activity/assistant-delta-appended", + threadId, + childTurnId: event.turnId, + itemId: event.itemId, + delta: event.delta, + }, + ]; + case "planDelta": + return [ + { + type: "subagent-activity/plan-delta-appended", + threadId, + childTurnId: event.turnId, + itemId: event.itemId, + delta: event.delta, + }, + ]; + case "textDelta": + if (notificationMethod === "item/reasoning/textDelta") return []; + return [ + { + type: "subagent-activity/text-delta-appended", + threadId, + childTurnId: event.turnId, + itemId: event.itemId, + label: event.label, + delta: event.delta, + kind: event.kind, + }, + ]; + case "toolOutputDelta": + return [ + { + type: "subagent-activity/tool-output-appended", + threadId, + childTurnId: event.turnId, + itemId: event.itemId, + delta: event.delta, + fallbackLabel: event.fallbackLabel, + }, + ]; + case "hookRunObserved": + return event.turnId + ? [{ type: "subagent-activity/item-observed", threadId, item: { ...event.item, turnId: event.turnId }, advance: true }] + : []; + case "autoReviewUpdated": + case "reviewWarning": + return [{ type: "subagent-activity/item-observed", threadId, item: event.item, advance: true }]; + case "itemOutputDelta": + case "turnDiffUpdated": + case "requestResolved": + case "systemNotice": + return []; + } +} + +function subagentTrackingActionsFromParentEvents(state: ChatState, events: readonly TurnRuntimeEvent[]): SubagentActivityAction[] { + const parentTurnId = activeTurnIdForState(state); + if (!parentTurnId) return []; + const threadIds = new Set(); + for (const event of events) { + if (event.type !== "itemUpserted" && event.type !== "itemCompleted") continue; + if (event.item.kind !== "agent" || event.item.turnId !== parentTurnId) continue; + for (const threadId of event.item.receiverThreadIds) threadIds.add(threadId); + for (const agent of event.item.agents) threadIds.add(agent.threadId); + } + return [...threadIds].map((threadId) => ({ type: "subagent-activity/tracked", threadId, parentTurnId })); +} + function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] { if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return []; switch (outcome.type) { @@ -167,6 +335,11 @@ function threadStartedPlan( notification: Extract, ): ChatNotificationPlan { const thread = threadFromAppServerRecord(notification.params.thread); + const activeParentTurnId = activeTurnIdForState(state); + const trackAction: SubagentActivityAction[] = + thread.provenance.kind === "subagent" && thread.provenance.parentThreadId === activeThreadId(state) && activeParentTurnId + ? [{ type: "subagent-activity/tracked", threadId: thread.id, parentTurnId: activeParentTurnId }] + : []; const effects: ChatNotificationEffect[] = notification.params.thread.ephemeral || thread.provenance.kind === "subagent" ? [] @@ -177,9 +350,9 @@ function threadStartedPlan( }, ]; if (activeThreadId(state) === notification.params.thread.id) { - return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects }; + return { actions: [...trackAction, { type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects }; } - return { actions: [], effects }; + return { actions: trackAction, effects }; } function threadGoalPlan( diff --git a/src/features/chat/app-server/inbound/notification-routing.ts b/src/features/chat/app-server/inbound/notification-routing.ts index 609ac17d..1177824b 100644 --- a/src/features/chat/app-server/inbound/notification-routing.ts +++ b/src/features/chat/app-server/inbound/notification-routing.ts @@ -25,7 +25,7 @@ export type ServerNotificationRoute = | { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification } | { kind: "ignored"; notification: ServerNotification } | { kind: "unhandled"; notification: ServerNotification } - | { kind: "inactive"; notification: ServerNotification }; + | { kind: "inactive"; notification: ServerNotification; scope: AppServerRouteScope }; type ServerNotificationScopeExtractors = Partial<{ [Method in ServerNotificationMethod]: (notification: Extract) => AppServerRouteScope; @@ -205,8 +205,8 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors = export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute { if (isThreadCatalogNotification(notification)) return { kind: "threadLifecycle", notification }; const routeScope = serverNotificationScope(notification); - if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification }; - if (isIdleThreadStreamUpdate(notification, routeScope, scope)) return { kind: "inactive", notification }; + if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification, scope: routeScope }; + if (isIdleThreadStreamUpdate(notification, routeScope, scope)) return { kind: "inactive", notification, scope: routeScope }; if (isStreamUpdateNotification(notification)) return { kind: "streamUpdate", notification }; if (isTurnLifecycleNotification(notification)) return { kind: "turnLifecycle", notification }; diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 69f18247..d3931f83 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -60,6 +60,14 @@ import type { } from "./actions"; import { definedPatch, patchObject } from "./patch"; import type { ChatPendingSubmissionState, PendingSubmissionAction } from "./pending-submission"; +import { + type ChatSubagentActivityState, + initialSubagentActivityState, + isSubagentActivityAction, + reduceSubagentActivitySlice, + type SubagentActivityAction, + subagentActivityWithParentTurn, +} from "./subagent-activity"; import { type ChatThreadStreamState, initialChatThreadStreamState, @@ -145,6 +153,7 @@ interface ChatStateShape { runtime: ChatRuntimeState; turn: ChatTurnState; threadStream: ChatThreadStreamState; + subagentActivity: ChatSubagentActivityState; pendingSubmission: ChatPendingSubmissionState | null; requests: ChatRequestState; composer: ChatComposerState; @@ -275,6 +284,7 @@ type ChatSliceAction = | RuntimeAction | RequestAction | ThreadStreamAction + | SubagentActivityAction | ComposerAction | UiAction; @@ -287,6 +297,7 @@ export function createChatState(): ChatState { runtime: initialChatRuntimeState(), turn: initialTurnState(), threadStream: initialThreadStreamState(), + subagentActivity: initialSubagentActivityState(), pendingSubmission: null, requests: initialRequestState(), composer: initialComposerState(), @@ -558,6 +569,7 @@ function reduceTurnStartedTransition(state: ChatState, action: TurnStartedAction threadStream: action.items ? threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items) : threadStreamStartActiveSegment(state.threadStream, action.turnId, []), + subagentActivity: initialSubagentActivityState(action.turnId), }); } @@ -567,6 +579,7 @@ function reduceTurnCompletedTransition(state: ChatState, action: TurnCompletedAc return patchChatState(state, { turn: { lifecycle }, threadStream: threadStreamWithItems(state.threadStream, action.items), + subagentActivity: initialSubagentActivityState(), connection: { ...state.connection, statusText: `Turn ${action.status}.` }, }); } @@ -586,6 +599,7 @@ function reduceTurnOptimisticStartedTransition(state: ChatState, action: TurnOpt turn: { lifecycle }, pendingSubmission: action.pendingSubmissionId ? null : state.pendingSubmission, threadStream: threadStreamStartActiveSegment(state.threadStream, null, [action.item]), + subagentActivity: initialSubagentActivityState(), }); } @@ -598,6 +612,7 @@ function reduceTurnStartAcknowledgedTransition(state: ChatState, action: TurnSta return patchChatState(state, { turn: { lifecycle }, threadStream: threadStreamWithActiveTurnItems(state.threadStream, action.turnId, action.items), + subagentActivity: subagentActivityWithParentTurn(state.subagentActivity, action.turnId), }); } @@ -607,6 +622,7 @@ function reduceTurnStartFailedTransition(state: ChatState, action: TurnStartFail return patchChatState(state, { turn: { lifecycle }, threadStream: threadStreamWithItems(state.threadStream, action.items), + subagentActivity: initialSubagentActivityState(), }); } @@ -640,6 +656,7 @@ function clearTurnScopedState(state: ChatState): ChatState { lifecycle: transitionChatTurnLifecycleState(state.turn.lifecycle, { type: "cleared" }), }, threadStream: threadStreamWithItems(state.threadStream, threadStreamItems(state.threadStream)), + subagentActivity: initialSubagentActivityState(), requests: initialRequestState(), ui: clearAllRequestDisclosures(state.ui), }); @@ -724,6 +741,9 @@ function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState pendingSubmission: state.pendingSubmission, requests: isRequestAction(action) ? reduceRequestSlice(state.requests, action) : state.requests, threadStream: isThreadStreamAction(action) ? reduceThreadStreamSlice(state.threadStream, action) : state.threadStream, + subagentActivity: isSubagentActivityAction(action) + ? reduceSubagentActivitySlice(state.subagentActivity, action) + : state.subagentActivity, composer: reduceComposerSlice(state.composer, action), ui: isUiAction(action) ? reduceUiSlice(state.ui, action) : state.ui, }); diff --git a/src/features/chat/application/state/subagent-activity.ts b/src/features/chat/application/state/subagent-activity.ts new file mode 100644 index 00000000..0e5b93ec --- /dev/null +++ b/src/features/chat/application/state/subagent-activity.ts @@ -0,0 +1,234 @@ +import { streamedTextThreadStreamItem, streamedToolOutputThreadStreamItem } from "../../domain/thread-stream/factories/streaming-items"; +import { normalizeProposedPlanMarkdown } from "../../domain/thread-stream/format/proposed-plan"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import { upsertThreadStreamItemById } from "../../domain/thread-stream/updates"; + +interface SubagentActivityEntry { + readonly threadId: string; + readonly childTurnId: string | null; + readonly latestItem: ThreadStreamItem | null; + readonly executionState: "running" | "completed" | "failed"; +} + +export interface ChatSubagentActivityState { + readonly parentTurnId: string | null; + readonly byThreadId: ReadonlyMap; +} + +export type SubagentActivityAction = + | { type: "subagent-activity/tracked"; threadId: string; parentTurnId: string } + | { type: "subagent-activity/turn-started"; threadId: string; childTurnId: string } + | { type: "subagent-activity/item-observed"; threadId: string; item: ThreadStreamItem; advance: boolean } + | { type: "subagent-activity/assistant-delta-appended"; threadId: string; childTurnId: string; itemId: string; delta: string } + | { type: "subagent-activity/plan-delta-appended"; threadId: string; childTurnId: string; itemId: string; delta: string } + | { + type: "subagent-activity/text-delta-appended"; + threadId: string; + childTurnId: string; + itemId: string; + label: string; + delta: string; + kind: "tool" | "hook" | "reasoning"; + } + | { + type: "subagent-activity/tool-output-appended"; + threadId: string; + childTurnId: string; + itemId: string; + delta: string; + fallbackLabel: string; + } + | { + type: "subagent-activity/turn-completed"; + threadId: string; + childTurnId: string; + items: readonly ThreadStreamItem[]; + executionState: "completed" | "failed"; + } + | { type: "subagent-activity/execution-state-changed"; threadId: string; executionState: "completed" | "failed" }; + +export function initialSubagentActivityState(parentTurnId: string | null = null): ChatSubagentActivityState { + return { parentTurnId, byThreadId: new Map() }; +} + +export function subagentActivityWithParentTurn(state: ChatSubagentActivityState, parentTurnId: string): ChatSubagentActivityState { + return state.parentTurnId === parentTurnId ? state : initialSubagentActivityState(parentTurnId); +} + +export function isSubagentActivityAction(action: { type: string }): action is SubagentActivityAction { + return action.type.startsWith("subagent-activity/"); +} + +export function reduceSubagentActivitySlice(state: ChatSubagentActivityState, action: SubagentActivityAction): ChatSubagentActivityState { + switch (action.type) { + case "subagent-activity/tracked": + return trackSubagent(state, action.threadId, action.parentTurnId); + case "subagent-activity/turn-started": + return updateTrackedEntry(state, action.threadId, (entry) => ({ + ...entry, + childTurnId: action.childTurnId, + latestItem: null, + executionState: "running", + })); + case "subagent-activity/item-observed": + return updateTrackedEntry(state, action.threadId, (entry) => { + if (isStaleChildTurn(entry, action.item.turnId)) return entry; + return { + ...entry, + childTurnId: action.item.turnId ?? entry.childTurnId, + latestItem: observedLatestItem(entry.latestItem, action.item, action.advance), + }; + }); + case "subagent-activity/assistant-delta-appended": + return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({ + ...entry, + childTurnId: action.childTurnId, + latestItem: appendAssistantDelta(entry.latestItem, action.itemId, action.childTurnId, action.delta), + })); + case "subagent-activity/plan-delta-appended": + return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({ + ...entry, + childTurnId: action.childTurnId, + latestItem: appendPlanDelta(entry.latestItem, action.itemId, action.childTurnId, action.delta), + })); + case "subagent-activity/text-delta-appended": + return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({ + ...entry, + childTurnId: action.childTurnId, + latestItem: appendTextDelta(entry.latestItem, action.itemId, action.childTurnId, action.label, action.delta, action.kind), + })); + case "subagent-activity/tool-output-appended": + return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({ + ...entry, + childTurnId: action.childTurnId, + latestItem: appendToolOutput(entry.latestItem, action.itemId, action.childTurnId, action.delta, action.fallbackLabel), + })); + case "subagent-activity/turn-completed": + return updateCurrentTurnEntry(state, action.threadId, action.childTurnId, (entry) => ({ + ...entry, + childTurnId: action.childTurnId, + latestItem: latestDisplayableItem(action.items) ?? entry.latestItem, + executionState: action.executionState, + })); + case "subagent-activity/execution-state-changed": + return updateTrackedEntry(state, action.threadId, (entry) => ({ ...entry, executionState: action.executionState })); + } +} + +function trackSubagent(state: ChatSubagentActivityState, threadId: string, parentTurnId: string): ChatSubagentActivityState { + const current = state.parentTurnId === parentTurnId ? state : initialSubagentActivityState(parentTurnId); + if (current.byThreadId.has(threadId)) return current; + const byThreadId = new Map(current.byThreadId); + byThreadId.set(threadId, { threadId, childTurnId: null, latestItem: null, executionState: "running" }); + return { ...current, byThreadId }; +} + +function updateTrackedEntry( + state: ChatSubagentActivityState, + threadId: string, + update: (entry: SubagentActivityEntry) => SubagentActivityEntry, +): ChatSubagentActivityState { + const entry = state.byThreadId.get(threadId); + if (!entry) return state; + const next = update(entry); + if (next === entry) return state; + const byThreadId = new Map(state.byThreadId); + byThreadId.set(threadId, next); + return { ...state, byThreadId }; +} + +function updateCurrentTurnEntry( + state: ChatSubagentActivityState, + threadId: string, + childTurnId: string, + update: (entry: SubagentActivityEntry) => SubagentActivityEntry, +): ChatSubagentActivityState { + return updateTrackedEntry(state, threadId, (entry) => (isStaleChildTurn(entry, childTurnId) ? entry : update(entry))); +} + +function isStaleChildTurn(entry: SubagentActivityEntry, childTurnId: string | null | undefined): boolean { + return Boolean(entry.childTurnId && childTurnId && entry.childTurnId !== childTurnId); +} + +function observedLatestItem(current: ThreadStreamItem | null, item: ThreadStreamItem, advance: boolean): ThreadStreamItem | null { + if (advance || !current) return item; + if (!sameSourceItem(current, item.id)) return current; + return upsertThreadStreamItemById([current], item)[0] ?? item; +} + +function appendAssistantDelta(current: ThreadStreamItem | null, itemId: string, turnId: string, delta: string): ThreadStreamItem { + if (current && sameSourceItem(current, itemId) && current.kind === "dialogue" && current.dialogueKind === "assistantResponse") { + return { + ...current, + text: `${current.text}${delta}`, + copyText: `${current.text}${delta}`, + turnId, + dialogueState: "streaming", + }; + } + return { + id: itemId, + kind: "dialogue", + dialogueKind: "assistantResponse", + role: "assistant", + text: delta, + copyText: delta, + turnId, + sourceItemId: itemId, + provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId: itemId }, + dialogueState: "streaming", + }; +} + +function appendPlanDelta(current: ThreadStreamItem | null, itemId: string, turnId: string, delta: string): ThreadStreamItem { + const previousText = + current && sameSourceItem(current, itemId) && current.kind === "dialogue" && current.role === "assistant" ? current.text : ""; + const text = normalizeProposedPlanMarkdown(`${previousText}${delta}`); + return { + id: itemId, + kind: "dialogue", + dialogueKind: "proposedPlan", + role: "assistant", + text, + copyText: text, + turnId, + sourceItemId: itemId, + provenance: { source: "appServer", channel: "notification", event: "streamingDelta", sourceItemId: itemId }, + dialogueState: "streaming", + }; +} + +function appendTextDelta( + current: ThreadStreamItem | null, + itemId: string, + turnId: string, + label: string, + delta: string, + kind: "tool" | "hook" | "reasoning", +): ThreadStreamItem { + if (current && sameSourceItem(current, itemId) && current.kind === kind) { + return { ...current, text: `${current.text ?? ""}${delta}`, turnId }; + } + return streamedTextThreadStreamItem({ id: itemId, turnId, label, delta, kind }); +} + +function appendToolOutput( + current: ThreadStreamItem | null, + itemId: string, + turnId: string, + delta: string, + fallbackLabel: string, +): ThreadStreamItem { + if (current && sameSourceItem(current, itemId) && (current.kind === "tool" || current.kind === "hook")) { + return { ...current, output: `${current.output ?? ""}${delta}`, turnId }; + } + return streamedToolOutputThreadStreamItem({ id: itemId, turnId, output: delta, fallbackLabel }); +} + +function latestDisplayableItem(items: readonly ThreadStreamItem[]): ThreadStreamItem | null { + return [...items].reverse().find((item) => item.kind !== "system" && !(item.kind === "dialogue" && item.role === "user")) ?? null; +} + +function sameSourceItem(item: ThreadStreamItem, sourceItemId: string): boolean { + return item.id === sourceItemId || item.sourceItemId === sourceItemId; +} diff --git a/src/features/chat/domain/thread-stream/semantics/active-turn.ts b/src/features/chat/domain/thread-stream/semantics/active-turn.ts index c39e9baa..084dbfc4 100644 --- a/src/features/chat/domain/thread-stream/semantics/active-turn.ts +++ b/src/features/chat/domain/thread-stream/semantics/active-turn.ts @@ -12,12 +12,19 @@ import { threadStreamIsCoordinationProgress } from "./predicates"; import type { ThreadStreamSemanticClassification } from "./types"; const ACTIVE_AGENT_PREVIEW_LIMIT = 96; +const ACTIVE_AGENT_ROW_LIMIT = 3; type AgentRunState = "running" | "completed" | "failed"; export interface ActiveTurnItemsContext { activeTurnId: string | null; items: readonly ThreadStreamItem[]; activeItems?: readonly ThreadStreamItem[] | undefined; + subagentActivities?: ReadonlyMap | undefined; +} + +export interface ActiveSubagentActivity { + readonly executionState: "running" | "completed" | "failed"; + readonly messagePreview: string | null; } export type ActiveTurnLiveItem = @@ -32,13 +39,13 @@ export type ActiveTurnLiveItem = }; export function activeTurnLiveItems( - input: Pick, + input: Pick, activeTurnId: string, ): ActiveTurnLiveItem[] { const items = input.activeItems ?? input.items; const semanticItems = threadStreamSemanticClassifications(items); const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(semanticItems, activeTurnId); - const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId) : null; + const agentSummary = agentSummaryAnchorId ? activeAgentRunSummary(items, activeTurnId, input.subagentActivities) : null; return semanticItems.flatMap((classification): ActiveTurnLiveItem[] => { const { item } = classification; @@ -56,7 +63,11 @@ export function threadStreamItemsWithoutActiveTaskProgress(items: readonly Threa return items.filter((item) => !threadStreamItemIsActiveTaskProgress(item, activeTurnId)); } -function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId: string | null): AgentRunSummary | null { +function activeAgentRunSummary( + items: readonly ThreadStreamItem[], + activeTurnId: string | null, + subagentActivities?: ReadonlyMap, +): AgentRunSummary | null { if (!activeTurnId) return null; const agentStatuses = new Map(); @@ -72,6 +83,23 @@ function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId: } } } + for (const [threadId, activity] of subagentActivities ?? []) { + const current = agentStatuses.get(threadId); + if (!current) { + agentStatuses.set(threadId, { + threadId, + status: activity.executionState, + executionState: activity.executionState, + message: null, + }); + continue; + } + agentStatuses.set(threadId, { + ...current, + status: activity.executionState === current.executionState ? current.status : activity.executionState, + executionState: activity.executionState, + }); + } if (agentStatuses.size === 0) return null; @@ -84,14 +112,17 @@ function activeAgentRunSummary(items: readonly ThreadStreamItem[], activeTurnId: if (summary.running === 0 && summary.failed === 0) return null; - summary.agents = agents + const runningAgents = agents .filter((agent) => agentRunState(agent) === "running") - .sort((a, b) => a.threadId.localeCompare(b.threadId)) .map((agent) => ({ threadId: agent.threadId, status: agent.status, - messagePreview: agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT), - })); + messagePreview: + subagentActivities?.get(agent.threadId)?.messagePreview ?? agentMessagePreview(agent.message, ACTIVE_AGENT_PREVIEW_LIMIT), + })) + .sort((a, b) => Number(Boolean(b.messagePreview)) - Number(Boolean(a.messagePreview)) || a.threadId.localeCompare(b.threadId)); + summary.agents = runningAgents.slice(0, ACTIVE_AGENT_ROW_LIMIT); + summary.additionalAgents = runningAgents.length - summary.agents.length; return summary; } diff --git a/src/features/chat/panel/shell-selectors.ts b/src/features/chat/panel/shell-selectors.ts index 9480bdf2..d9470b48 100644 --- a/src/features/chat/panel/shell-selectors.ts +++ b/src/features/chat/panel/shell-selectors.ts @@ -40,6 +40,7 @@ export interface ChatPanelThreadStreamModel { readonly turn: ChatState["turn"]; readonly runtimeCollaborationMode: ChatState["runtime"]["pending"]["collaborationMode"]; readonly threadStream: ChatState["threadStream"]; + readonly subagentActivity: ChatState["subagentActivity"]; readonly pendingSubmission: ChatState["pendingSubmission"]; readonly requests: ChatState["requests"]; readonly disclosureDetails: ChatState["ui"]["disclosures"]["details"]; @@ -114,6 +115,7 @@ export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadSt turn: state.turn, runtimeCollaborationMode: state.runtime.pending.collaborationMode, threadStream: state.threadStream, + subagentActivity: state.subagentActivity, pendingSubmission: state.pendingSubmission, requests: state.requests, disclosureDetails: state.ui.disclosures.details, diff --git a/src/features/chat/panel/surface/thread-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts index d34c5d5d..79f01b99 100644 --- a/src/features/chat/panel/surface/thread-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -19,7 +19,9 @@ import { type PlanImplementationTarget, threadStreamSegmentsEmpty, } from "../../domain/thread-stream/selectors"; +import type { ActiveSubagentActivity } from "../../domain/thread-stream/semantics/active-turn"; import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; +import { subagentActivityPreview } from "../../presentation/thread-stream/subagent-activity-preview"; import type { ThreadStreamTextActionTargets } from "../../presentation/thread-stream/text-view"; import { type ThreadStreamViewBlock, threadStreamViewBlocks } from "../../presentation/thread-stream/view-model"; import type { ThreadStreamContext, ThreadStreamDisclosureBucket, ThreadStreamDisclosureState } from "../../ui/thread-stream/context"; @@ -150,6 +152,14 @@ function threadStreamStateProjection( approvalDetails: model.disclosureApprovalDetails, }; const workspaceRoot = model.activeThreadCwd ?? context.vaultPath; + const subagentActivities = new Map(); + for (const [threadId, activity] of model.subagentActivity.byThreadId) { + const preview = subagentActivityPreview(activity.latestItem, workspaceRoot); + subagentActivities.set(threadId, { + executionState: activity.executionState, + messagePreview: preview, + }); + } const turnBusy = chatTurnBusy(model.turn); const rollbackCandidate = !turnBusy && model.rollbackAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null; const forkCandidates = !turnBusy && model.forkAllowed ? forkCandidatesFromItems(canonicalItems) : []; @@ -183,6 +193,7 @@ function threadStreamStateProjection( turnDiffs: model.threadStream.turnDiffs, textActionTargetsByItemId, pendingRequests, + subagentActivities, }), }; } diff --git a/src/features/chat/presentation/thread-stream/status-view.ts b/src/features/chat/presentation/thread-stream/status-view.ts index cf1606ee..d44ce24b 100644 --- a/src/features/chat/presentation/thread-stream/status-view.ts +++ b/src/features/chat/presentation/thread-stream/status-view.ts @@ -132,7 +132,7 @@ function agentRunSummaryRow(agent: AgentRunSummaryAgent): { threadId: string; th return { threadId: agent.threadId, threadLabel: shortThreadId(agent.threadId), - status: agent.messagePreview ? `${agent.status}: ${agent.messagePreview}` : agent.status, + status: agent.messagePreview ?? agent.status, }; } diff --git a/src/features/chat/presentation/thread-stream/subagent-activity-preview.ts b/src/features/chat/presentation/thread-stream/subagent-activity-preview.ts new file mode 100644 index 00000000..b3be2854 --- /dev/null +++ b/src/features/chat/presentation/thread-stream/subagent-activity-preview.ts @@ -0,0 +1,51 @@ +import { truncate } from "../../../../domain/display/text-preview"; +import { agentMessagePreview } from "../../domain/thread-stream/format/agent-message-preview"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import { detailView } from "./detail-view"; + +const SUBAGENT_ACTIVITY_PREVIEW_LIMIT = 96; + +export function subagentActivityPreview(item: ThreadStreamItem | null, workspaceRoot?: string | null): string | null { + if (!item) return null; + switch (item.kind) { + case "dialogue": + return item.role === "assistant" ? previewText(item.text) : null; + case "reasoning": + return previewText(stripStreamingLabel(item.text, "reasoning")) ?? "Reasoning"; + case "taskProgress": + return previewText(currentTaskStep(item) ?? item.explanation) ?? "Updating plan"; + case "contextCompaction": + return "Compacting context"; + case "wait": + return previewText(item.text) ?? "Waiting for agents"; + case "system": + case "goal": + case "approvalResult": + case "userInputResult": + return null; + default: + return previewText(detailSummary(item, workspaceRoot)); + } +} + +function detailSummary(item: ThreadStreamItem, workspaceRoot?: string | null): string { + const view = detailView(item, workspaceRoot); + if (view.summary !== "details") return view.summary; + if ("output" in item && typeof item.output === "string" && item.output.trim().length > 0) return item.output; + return view.label; +} + +function currentTaskStep(item: Extract): string | null { + return item.steps.find((step) => step.status === "inProgress")?.step ?? null; +} + +function stripStreamingLabel(text: string | undefined, label: string): string | null { + if (!text) return null; + const prefix = `${label}:`; + return text.startsWith(prefix) ? text.slice(prefix.length).trimStart() : text; +} + +function previewText(text: string | null | undefined): string | null { + const preview = agentMessagePreview(text ?? null, SUBAGENT_ACTIVITY_PREVIEW_LIMIT); + return preview ? truncate(preview, SUBAGENT_ACTIVITY_PREVIEW_LIMIT) : null; +} diff --git a/src/features/chat/presentation/thread-stream/view-model.ts b/src/features/chat/presentation/thread-stream/view-model.ts index 1992e2d4..2b67287c 100644 --- a/src/features/chat/presentation/thread-stream/view-model.ts +++ b/src/features/chat/presentation/thread-stream/view-model.ts @@ -1,6 +1,10 @@ import type { ThreadStreamItem } from "../../domain/thread-stream/items"; import { threadStreamItemsEmpty } from "../../domain/thread-stream/selectors"; -import { activeTurnLiveItems, threadStreamItemsWithoutActiveTaskProgress } from "../../domain/thread-stream/semantics/active-turn"; +import { + type ActiveSubagentActivity, + activeTurnLiveItems, + threadStreamItemsWithoutActiveTaskProgress, +} from "../../domain/thread-stream/semantics/active-turn"; import type { ThreadStreamSemanticClassification } from "../../domain/thread-stream/semantics/types"; import type { PendingRequestBlockSnapshot } from "../pending-requests/view-model"; import { type DetailView, detailView } from "./detail-view"; @@ -25,6 +29,7 @@ export interface ThreadStreamPresentationBlockInput { turnDiffs?: ReadonlyMap | undefined; textActionTargetsByItemId?: ReadonlyMap | undefined; pendingRequests?: PendingRequestThreadStreamBlockInput | null | undefined; + subagentActivities?: ReadonlyMap | undefined; } type ThreadStreamRenderFamily = "text" | "detail" | "status"; diff --git a/src/features/chat/ui/thread-stream/status.tsx b/src/features/chat/ui/thread-stream/status.tsx index 690d7f50..29194c55 100644 --- a/src/features/chat/ui/thread-stream/status.tsx +++ b/src/features/chat/ui/thread-stream/status.tsx @@ -157,12 +157,16 @@ function AgentSummaryRows({ }} > {agent.threadLabel} - {agent.status} + + {agent.status} + ) : ( <> {agent.threadLabel} - {agent.status} + + {agent.status} + )} diff --git a/src/styles/24-chat-stream-items.css b/src/styles/24-chat-stream-items.css index 7b0c9244..a8c1ad28 100644 --- a/src/styles/24-chat-stream-items.css +++ b/src/styles/24-chat-stream-items.css @@ -151,7 +151,9 @@ .codex-panel__agent-status { min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .codex-panel__agent-list--summary { diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index c97370cd..7ed2155e 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -1397,6 +1397,144 @@ describe("ChatInboundHandler", () => { expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); }); + it("tracks direct subagent activity without admitting unrelated inactive notifications", () => { + const handler = handlerForState(activeRunningState()); + + handler.handleNotification({ + method: "thread/started", + params: { thread: directSubagentThread("child", "thread-active") }, + } satisfies Extract); + handler.handleNotification({ + method: "turn/started", + params: { + threadId: "child", + turn: { + id: "child-turn", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + handler.handleNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "child", + turnId: "child-turn", + itemId: "child-reasoning", + summaryIndex: 0, + delta: "Inspecting notification routing", + }, + } satisfies Extract); + handler.handleNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + threadId: "unrelated", + turnId: "other-turn", + itemId: "other-reasoning", + summaryIndex: 0, + delta: "Should stay hidden", + }, + } satisfies Extract); + + expect(handler.currentState().subagentActivity.byThreadId.get("child")).toMatchObject({ + childTurnId: "child-turn", + latestItem: { + id: "child-reasoning", + kind: "reasoning", + text: "reasoning: Inspecting notification routing", + }, + }); + expect(handler.currentState().subagentActivity.byThreadId.has("unrelated")).toBe(false); + + handler.handleNotification({ + method: "turn/completed", + params: { + threadId: "child", + turn: { + id: "child-turn", + status: "interrupted", + startedAt: 1, + completedAt: 2, + durationMs: 1, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + + expect(handler.currentState().subagentActivity.byThreadId.get("child")?.executionState).toBe("failed"); + }); + + it("does not track a nested subagent as direct parent activity", () => { + const handler = handlerForState(activeRunningState()); + + handler.handleNotification({ + method: "thread/started", + params: { thread: directSubagentThread("grandchild", "child") }, + } satisfies Extract); + + expect(handler.currentState().subagentActivity.byThreadId.size).toBe(0); + }); + + it("tracks a child from the parent subagent activity item without rendering that protocol marker", () => { + const handler = handlerForState(activeRunningState()); + + handler.handleNotification({ + method: "item/started", + params: { + threadId: "thread-active", + turnId: "turn-active", + startedAtMs: 1, + item: { + type: "subAgentActivity", + id: "subagent-started", + kind: "started", + agentThreadId: "child", + agentPath: "child", + }, + }, + } satisfies Extract); + + expect(handler.currentState().subagentActivity.byThreadId.get("child")).toMatchObject({ + threadId: "child", + executionState: "running", + latestItem: null, + }); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); + }); + + it("marks an interrupted child activity as failed without rendering the protocol marker", () => { + const handler = handlerForState(activeRunningState()); + + handler.handleNotification({ + method: "item/completed", + params: { + threadId: "thread-active", + turnId: "turn-active", + completedAtMs: 2, + item: { + type: "subAgentActivity", + id: "subagent-interrupted", + kind: "interrupted", + agentThreadId: "child", + agentPath: "child", + }, + }, + } satisfies Extract); + + expect(handler.currentState().subagentActivity.byThreadId.get("child")).toMatchObject({ + threadId: "child", + executionState: "failed", + }); + expect(chatStateThreadStreamItems(handler.currentState())).toEqual([]); + }); + it("reconciles optimistic user echoes by client id before falling back to same-turn text only when client ids are absent", () => { let state = activeRunningState(); state = withChatStateThreadStreamItems(state, [ @@ -2099,6 +2237,26 @@ function appServerThread(id: string, cwd: string): ThreadStartedNotification["pa }; } +function directSubagentThread(id: string, parentThreadId: string): ThreadStartedNotification["params"]["thread"] { + return { + ...appServerThread(id, "/workspace/active"), + parentThreadId, + source: { + subAgent: { + thread_spawn: { + parent_thread_id: parentThreadId, + depth: 1, + agent_path: null, + agent_nickname: "Scout", + agent_role: "explorer", + }, + }, + }, + agentNickname: "Scout", + agentRole: "explorer", + }; +} + function panelThread(id: string): PanelThread { return { id, diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index 16e156e8..e188cb2b 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -504,6 +504,11 @@ describe("chatReducer", () => { state = withChatStateThreadStreamItems(state, [dialogueItem("kept")]); state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } }); state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["kept:details"]) } } }); + state = chatReducer(state, { + type: "subagent-activity/tracked", + threadId: "child", + parentTurnId: "turn", + }); const next = chatReducer(state, { type: "turn/scoped-cleared" }); @@ -513,6 +518,7 @@ describe("chatReducer", () => { expect(next.requests.pendingUserInputs).toEqual([]); expect(next.requests.userInputDrafts.size).toBe(0); expect(chatStateThreadStreamItems(next)).toEqual([dialogueItem("kept")]); + expect(next.subagentActivity.byThreadId.size).toBe(0); expect([...next.ui.disclosures.approvalDetails]).toEqual([]); expect([...next.ui.disclosures.textDetails]).toEqual(["kept:details"]); }); diff --git a/tests/features/chat/application/state/subagent-activity.test.ts b/tests/features/chat/application/state/subagent-activity.test.ts new file mode 100644 index 00000000..e7fa054f --- /dev/null +++ b/tests/features/chat/application/state/subagent-activity.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + initialSubagentActivityState, + reduceSubagentActivitySlice, +} from "../../../../../src/features/chat/application/state/subagent-activity"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; + +describe("subagent activity state", () => { + it("tracks a child and accumulates its active reasoning summary", () => { + let state = reduceSubagentActivitySlice(initialSubagentActivityState("parent-turn"), { + type: "subagent-activity/tracked", + threadId: "child", + parentTurnId: "parent-turn", + }); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/turn-started", + threadId: "child", + childTurnId: "child-turn", + }); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/text-delta-appended", + threadId: "child", + childTurnId: "child-turn", + itemId: "reasoning", + label: "reasoning", + delta: "Inspecting ", + kind: "reasoning", + }); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/text-delta-appended", + threadId: "child", + childTurnId: "child-turn", + itemId: "reasoning", + label: "reasoning", + delta: "routing", + kind: "reasoning", + }); + + expect(state.byThreadId.get("child")).toMatchObject({ + childTurnId: "child-turn", + latestItem: { id: "reasoning", kind: "reasoning", text: "reasoning: Inspecting routing" }, + }); + }); + + it("does not let an older item completion replace a newer activity", () => { + let state = trackedState(); + state = observe(state, reasoningItem("older", "Reading files"), true); + state = observe(state, reasoningItem("newer", "Running tests"), true); + state = observe(state, { ...reasoningItem("older", "Finished reading"), executionState: "completed" }, false); + + expect(state.byThreadId.get("child")?.latestItem).toMatchObject({ + id: "newer", + text: "Running tests", + }); + }); + + it("uses the last displayable canonical item when the child turn completes", () => { + let state = trackedState(); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/turn-completed", + threadId: "child", + childTurnId: "child-turn", + items: [ + { id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "work" }, + reasoningItem("reasoning", "Done checking"), + { + id: "answer", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", + role: "assistant", + text: "Everything passes.", + }, + ], + executionState: "completed", + }); + + expect(state.byThreadId.get("child")?.latestItem).toMatchObject({ + id: "answer", + text: "Everything passes.", + }); + }); + + it("ignores delayed notifications from an older child turn", () => { + let state = trackedState(); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/turn-started", + threadId: "child", + childTurnId: "new-turn", + }); + state = observe(state, reasoningItem("current", "Current work", "new-turn"), true); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/text-delta-appended", + threadId: "child", + childTurnId: "old-turn", + itemId: "stale", + label: "reasoning", + delta: "Older work", + kind: "reasoning", + }); + state = reduceSubagentActivitySlice(state, { + type: "subagent-activity/turn-completed", + threadId: "child", + childTurnId: "old-turn", + items: [reasoningItem("stale", "Older work", "old-turn")], + executionState: "completed", + }); + + expect(state.byThreadId.get("child")).toMatchObject({ + childTurnId: "new-turn", + executionState: "running", + latestItem: { id: "current", text: "Current work" }, + }); + }); +}); + +function trackedState() { + return reduceSubagentActivitySlice(initialSubagentActivityState("parent-turn"), { + type: "subagent-activity/tracked", + threadId: "child", + parentTurnId: "parent-turn", + }); +} + +function observe(state: ReturnType, item: ThreadStreamItem, advance: boolean): ReturnType { + return reduceSubagentActivitySlice(state, { + type: "subagent-activity/item-observed", + threadId: "child", + item, + advance, + }); +} + +function reasoningItem(id: string, text: string, turnId = "child-turn"): ThreadStreamItem { + return { id, kind: "reasoning", role: "tool", turnId, text }; +} diff --git a/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts b/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts index b3a00cd9..15422d43 100644 --- a/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts +++ b/tests/features/chat/domain/thread-stream/semantics/active-turn.test.ts @@ -48,12 +48,113 @@ describe("active turn semantics", () => { failed: 1, agents: [ { threadId: "a", status: "running", messagePreview: "Inspecting renderer tests" }, - { threadId: "c", status: "running", messagePreview: null }, { threadId: "d", status: "running", messagePreview: "Reviewing details" }, { threadId: "e", status: "running", messagePreview: "Checking scroll behavior" }, - { threadId: "fallback-child", status: "inProgress", messagePreview: null }, ], - additionalAgents: 0, + additionalAgents: 2, + }); + }); + + it("prefers live activity previews and caps visible rows", () => { + const items = [ + agentItem({ + receiverThreadIds: ["a", "b", "c", "d"], + agents: [ + { threadId: "a", status: "running", executionState: "running", message: "Parent fallback" }, + { threadId: "b", status: "running", executionState: "running", message: null }, + { threadId: "c", status: "running", executionState: "running", message: null }, + { threadId: "d", status: "running", executionState: "running", message: null }, + ], + }), + ]; + const summary = activeTurnLiveItems( + { + items, + subagentActivities: new Map([ + ["a", { executionState: "running", messagePreview: "Inspecting notification routing" }], + ["b", { executionState: "running", messagePreview: "Running tests" }], + ]), + }, + "t1", + ).find((item) => item.kind === "agentSummary")?.summary; + + expect(summary).toMatchObject({ + running: 4, + agents: [ + { threadId: "a", messagePreview: "Inspecting notification routing" }, + { threadId: "b", messagePreview: "Running tests" }, + { threadId: "c", messagePreview: null }, + ], + additionalAgents: 1, + }); + }); + + it("uses tracked live activity when an in-progress wait item has no receiver state yet", () => { + const summary = activeTurnLiveItems( + { + items: [agentItem({ receiverThreadIds: [], agents: [] })], + subagentActivities: new Map([["child", { executionState: "running", messagePreview: "Reading repository instructions" }]]), + }, + "t1", + ).find((item) => item.kind === "agentSummary")?.summary; + + expect(summary).toMatchObject({ + running: 1, + agents: [{ threadId: "child", status: "running", messagePreview: "Reading repository instructions" }], + }); + }); + + it("prefers terminal child activity over stale running parent state", () => { + const items = [ + agentItem({ + receiverThreadIds: ["completed", "failed"], + agents: [ + { threadId: "completed", status: "running", executionState: "running", message: null }, + { threadId: "failed", status: "running", executionState: "running", message: null }, + ], + }), + ]; + const summary = activeTurnLiveItems( + { + items, + subagentActivities: new Map([ + ["completed", { executionState: "completed", messagePreview: "Done" }], + ["failed", { executionState: "failed", messagePreview: "Interrupted" }], + ]), + }, + "t1", + ).find((item) => item.kind === "agentSummary")?.summary; + + expect(summary).toMatchObject({ + running: 0, + completed: 1, + failed: 1, + agents: [], + }); + }); + + it("prefers a running child activity over a completed spawn item", () => { + const summary = activeTurnLiveItems( + { + items: [ + agentItem({ + tool: "spawnAgent", + status: "completed", + executionState: "completed", + receiverThreadIds: ["child"], + agents: [], + }), + ], + subagentActivities: new Map([["child", { executionState: "running", messagePreview: "Inspecting files" }]]), + }, + "t1", + ).find((item) => item.kind === "agentSummary")?.summary; + + expect(summary).toMatchObject({ + running: 1, + completed: 0, + failed: 0, + agents: [{ threadId: "child", status: "running", messagePreview: "Inspecting files" }], }); }); diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 30a33612..d735acb1 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -8,7 +8,7 @@ import type { RuntimeConfigSnapshot } from "../../../../../src/domain/runtime/co import { createServerDiagnostics } from "../../../../../src/domain/server/diagnostics"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import type { Thread } from "../../../../../src/domain/threads/model"; -import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; import { @@ -93,6 +93,57 @@ describe("chat panel surface projections", () => { expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true'); }); + it("projects the latest direct subagent activity into the live agent summary", () => { + let state = chatStateFixture({ activeThread: { id: "parent", cwd: "/vault" } }); + state = chatReducer(state, { + type: "turn/started", + threadId: "parent", + turnId: "parent-turn", + items: [ + { + id: "agent", + kind: "agent", + role: "tool", + turnId: "parent-turn", + tool: "wait", + status: "inProgress", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "running", executionState: "running", message: null }], + executionState: "running", + }, + ], + }); + state = chatReducer(state, { + type: "subagent-activity/tracked", + threadId: "child", + parentTurnId: "parent-turn", + }); + state = chatReducer(state, { + type: "subagent-activity/text-delta-appended", + threadId: "child", + childTurnId: "child-turn", + itemId: "reasoning", + label: "reasoning", + delta: "Inspecting notification routing", + kind: "reasoning", + }); + + const projection = threadStreamSurfaceProjectionFromModel(selectChatPanelThreadStream(state), threadStreamSurfaceContext()); + const summary = projection.blocks.find((block) => block.kind === "liveAgentSummary"); + + expect(summary).toMatchObject({ + kind: "liveAgentSummary", + view: { + summary: "Agents 1 running", + rows: [{ threadId: "child", status: "Inspecting notification routing" }], + }, + }); + }); + it("resolves persisted reference titles from the thread catalog", () => { let state = chatStateFixture({ threadList: { diff --git a/tests/features/chat/presentation/thread-stream/status-view.test.ts b/tests/features/chat/presentation/thread-stream/status-view.test.ts new file mode 100644 index 00000000..34cf13e2 --- /dev/null +++ b/tests/features/chat/presentation/thread-stream/status-view.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { agentRunSummaryView } from "../../../../../src/features/chat/presentation/thread-stream/status-view"; + +describe("agent run summary view", () => { + it("uses the activity preview without repeating the running status", () => { + const view = agentRunSummaryView({ + running: 1, + completed: 0, + failed: 0, + agents: [ + { + threadId: "019agent", + status: "running", + messagePreview: "Inspecting notification routing", + }, + ], + additionalAgents: 0, + }); + + expect(view.rows).toEqual([ + { + threadId: "019agent", + threadLabel: "019agent", + status: "Inspecting notification routing", + }, + ]); + }); +}); diff --git a/tests/features/chat/presentation/thread-stream/subagent-activity-preview.test.ts b/tests/features/chat/presentation/thread-stream/subagent-activity-preview.test.ts new file mode 100644 index 00000000..ecd5f5e3 --- /dev/null +++ b/tests/features/chat/presentation/thread-stream/subagent-activity-preview.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items"; +import { subagentActivityPreview } from "../../../../../src/features/chat/presentation/thread-stream/subagent-activity-preview"; + +describe("subagent activity preview", () => { + it("shows streamed reasoning without its transport label", () => { + expect( + subagentActivityPreview({ + id: "reasoning", + kind: "reasoning", + role: "tool", + text: "reasoning: Inspecting notification routing", + }), + ).toBe("Inspecting notification routing"); + }); + + it("prefers the active task step", () => { + expect( + subagentActivityPreview({ + id: "tasks", + kind: "taskProgress", + role: "tool", + explanation: "Implementation plan", + steps: [ + { step: "Read code", status: "completed" }, + { step: "Patch notification routing", status: "inProgress" }, + ], + status: "inProgress", + }), + ).toBe("Patch notification routing"); + }); + + it("reuses the compact command target summary", () => { + expect( + subagentActivityPreview({ + id: "command", + kind: "command", + role: "tool", + commandAction: "search", + commandTarget: { kind: "search", query: "inactive", path: "/vault/src" }, + command: 'rg "inactive" src', + cwd: "/vault", + status: "inProgress", + }), + ).toBe("inactive in src"); + }); + + it("uses the first non-empty assistant line and omits user items", () => { + const assistant: ThreadStreamItem = { + id: "answer", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", + role: "assistant", + text: "\n Tests are passing. \nMore detail", + }; + const user: ThreadStreamItem = { + id: "prompt", + kind: "dialogue", + dialogueKind: "user", + role: "user", + text: "Do the work", + }; + + expect(subagentActivityPreview(assistant)).toBe("Tests are passing."); + expect(subagentActivityPreview(user)).toBeNull(); + }); +}); diff --git a/tests/features/chat/ui/thread-stream/stream-items.test.tsx b/tests/features/chat/ui/thread-stream/stream-items.test.tsx index ecf2978d..deb48f5d 100644 --- a/tests/features/chat/ui/thread-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/thread-stream/stream-items.test.tsx @@ -532,11 +532,11 @@ describe("thread stream item renderer decisions", () => { expect(summary.classList.contains("codex-panel__agent-summary")).toBe(true); expect(summary.textContent).toContain("agents"); expect(summary.textContent).toContain("Agents 1 running, 1 done"); - expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); + expect(summary.textContent).toContain("runningInspecting renderer"); expect(summary.textContent).not.toContain("donecompleted"); expect(summary.querySelector('[aria-label="Open agent thread"]')).toBeNull(); const open = expectPresent(summary.querySelector(".codex-panel__agent-row--interactive")); - expect(open.textContent).toBe("runningrunning: Inspecting renderer"); + expect(open.textContent).toBe("runningInspecting renderer"); open.click(); expect(openThreadInNewView).toHaveBeenCalledWith("running"); });