mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
feat(chat): show live subagent activity summaries
This commit is contained in:
parent
760421613d
commit
ef5d1d803d
20 changed files with 1106 additions and 25 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
234
src/features/chat/application/state/subagent-activity.ts
Normal file
234
src/features/chat/application/state/subagent-activity.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
136
tests/features/chat/application/state/subagent-activity.test.ts
Normal file
136
tests/features/chat/application/state/subagent-activity.test.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue