feat(chat): show live subagent activity summaries

This commit is contained in:
murashit 2026-07-18 17:45:01 +09:00
parent 760421613d
commit ef5d1d803d
20 changed files with 1106 additions and 25 deletions

View file

@ -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<typeof turnRuntimeEventsFromNotification>[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<typeof turnRuntimeEventsFromNotification>[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<string>();
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<ThreadLifecycleNotification, { method: "thread/started" }>,
): 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(

View file

@ -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<ServerNotification, { method: Method }>) => 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 };

View file

@ -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,
});

View file

@ -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<string, SubagentActivityEntry>;
}
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;
}

View file

@ -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<string, ActiveSubagentActivity> | 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<ActiveTurnItemsContext, "items" | "activeItems">,
input: Pick<ActiveTurnItemsContext, "items" | "activeItems" | "subagentActivities">,
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<string, ActiveSubagentActivity>,
): AgentRunSummary | null {
if (!activeTurnId) return null;
const agentStatuses = new Map<string, AgentStateSummary>();
@ -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;
}

View file

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

View file

@ -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<string, ActiveSubagentActivity>();
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,
}),
};
}

View file

@ -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,
};
}

View file

@ -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<ThreadStreamItem, { kind: "taskProgress" }>): 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;
}

View file

@ -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<string, string> | undefined;
textActionTargetsByItemId?: ReadonlyMap<string, ThreadStreamTextActionTargets> | undefined;
pendingRequests?: PendingRequestThreadStreamBlockInput | null | undefined;
subagentActivities?: ReadonlyMap<string, ActiveSubagentActivity> | undefined;
}
type ThreadStreamRenderFamily = "text" | "detail" | "status";

View file

@ -157,12 +157,16 @@ function AgentSummaryRows({
}}
>
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
<span className="codex-panel__agent-status" title={agent.status}>
{agent.status}
</span>
</div>
) : (
<>
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
<span className="codex-panel__agent-status" title={agent.status}>
{agent.status}
</span>
</>
)}
</li>

View file

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

View file

@ -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<ServerNotification, { method: "thread/started" }>);
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<ServerNotification, { method: "turn/started" }>);
handler.handleNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "child",
turnId: "child-turn",
itemId: "child-reasoning",
summaryIndex: 0,
delta: "Inspecting notification routing",
},
} satisfies Extract<ServerNotification, { method: "item/reasoning/summaryTextDelta" }>);
handler.handleNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "unrelated",
turnId: "other-turn",
itemId: "other-reasoning",
summaryIndex: 0,
delta: "Should stay hidden",
},
} satisfies Extract<ServerNotification, { method: "item/reasoning/summaryTextDelta" }>);
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<ServerNotification, { method: "turn/completed" }>);
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<ServerNotification, { method: "thread/started" }>);
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<ServerNotification, { method: "item/started" }>);
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<ServerNotification, { method: "item/completed" }>);
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,

View file

@ -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"]);
});

View file

@ -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<typeof trackedState>, item: ThreadStreamItem, advance: boolean): ReturnType<typeof trackedState> {
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 };
}

View file

@ -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" }],
});
});

View file

@ -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: {

View file

@ -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",
},
]);
});
});

View file

@ -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();
});
});

View file

@ -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<HTMLButtonElement>('[aria-label="Open agent thread"]')).toBeNull();
const open = expectPresent(summary.querySelector<HTMLElement>(".codex-panel__agent-row--interactive"));
expect(open.textContent).toBe("runningrunning: Inspecting renderer");
expect(open.textContent).toBe("runningInspecting renderer");
open.click();
expect(openThreadInNewView).toHaveBeenCalledWith("running");
});