From 3a32480abd11c385d80a318fbe82d2b095fbfd09 Mon Sep 17 00:00:00 2001 From: Zeptiny Date: Tue, 11 Aug 2026 03:07:39 -0300 Subject: [PATCH] fix(ipc): bound session and subagent payloads --- CONCEPTS.md | 3 +- electron/src/main/agents/manager.ts | 11 +- electron/src/main/agents/subagent-events.ts | 4 +- electron/src/main/ipc/chat/events.ts | 27 ++- electron/src/main/ipc/chat/persist.ts | 12 +- electron/src/main/ipc/payload-schemas.ts | 5 + electron/src/main/ipc/session.ts | 16 +- electron/src/main/ipc/subagents.ts | 42 ++++- electron/src/preload/index.ts | 9 +- electron/src/renderer/components/ChatView.tsx | 11 +- electron/src/renderer/components/Sidebar.tsx | 14 +- .../src/renderer/components/SubagentView.tsx | 28 ++- electron/src/renderer/hooks/useSession.ts | 15 +- electron/src/renderer/hooks/useSubagents.ts | 98 +++++++++-- .../src/renderer/utils/subagent-stream.ts | 31 +--- electron/src/shared/types/ipc-schemas.ts | 38 +++- electron/src/shared/types/ipc.ts | 40 ++++- electron/src/shared/types/session.ts | 10 ++ electron/src/shared/types/subagent.ts | 60 +++++-- electron/src/shared/usage.ts | 3 + electron/tests/unit/chat-ipc.test.ts | 16 +- .../tests/unit/preload-validation.test.ts | 5 +- .../tests/unit/session-persistence.test.ts | 16 +- electron/tests/unit/subagent-ipc.test.ts | 101 +++++++++-- electron/tests/unit/subagent-runtime.test.ts | 13 +- .../unit/subagent-snapshot-eviction.test.ts | 23 ++- electron/tests/unit/subagent-view.test.ts | 40 ++++- electron/tests/unit/use-session-cache.test.ts | 48 +++++- .../tests/unit/use-subagents-detail.test.ts | 11 +- .../unit/use-subagents-lazy-detail.test.tsx | 163 ++++++++++++++++++ .../tests/unit/use-subagents-live.test.ts | 74 ++++---- 31 files changed, 784 insertions(+), 203 deletions(-) create mode 100644 electron/tests/unit/use-subagents-lazy-detail.test.tsx diff --git a/CONCEPTS.md b/CONCEPTS.md index 1259bb10..065e302f 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -14,7 +14,8 @@ Shared domain vocabulary for the orchid project. This file defines terms used ac - **Live Delta Event** — A typed incremental update from a subagent run (`SubagentDeltaEvent`: `spawned`, `text_delta`, `thinking_delta`, `tool_start`, `tool_args_delta`, `tool_result`, `usage`, `terminal`), replacing full-projection broadcasts. Every delta carries `sessionId`, `subagentId`, `runId`, `sequence`, and `sessionRevision`; deltas are batched into one `SubagentEvent` envelope per IPC flush. - **Session Revision** — A per-session monotonic counter stamped on every subagent live event and snapshot. The renderer uses it to reject stale snapshots and as the floor when reseeding after hydration-buffer overflow. -- **Durable Handoff** — The transfer of subagent output from ephemeral live state to the persisted `SubagentRecord`. On the live-event path the renderer receives the durable record exactly twice — as a seed at spawn and authoritatively at terminal settlement — never per delta. Snapshots, reseeding after hydration-buffer overflow, and lazy hydration may additionally deliver the record outside that path. +- **Durable Handoff** — The transfer of subagent output from ephemeral live state to the persisted `SubagentRecord`. On the live-event path the renderer receives a lightweight `SubagentSummary` at spawn and terminal settlement, never a historical transcript per delta. Summary snapshots and reseeding keep list state current; the full durable record crosses IPC only when its row is selected for transcript detail. +- **Subagent Summary** — The bounded list-row representation of a subagent: identity, role, task, lifecycle timestamps/status, parent-chain attribution, and pre-aggregated usage. It deliberately excludes the durable chain, result, and error transcript payloads. - **Queued State** — A first-class runtime state (`queued`) for a subagent spawn or resume that exceeds the admission limits (`subagents.max_active_global`, `subagents.max_active_per_session`). Queued records park in a bounded FIFO queue (`subagents.max_queued`) and are admitted on terminal transitions with per-session round-robin fairness. The state is ephemeral like `pending`: visible end-to-end (runtime, IPC, delegate result, UI) but never persisted for fresh spawns — a durable row is written only at admission, and a crash loses queued work. Exception: a resume-queued record (one parked by `follow_up_subagent`) keeps its existing durable row via the runtime `_resumeQueued` marker, so the reopened chain and follow-up message survive a crash while queued; cancelling a resume-queued record persists the interrupted state through the normal terminal-wave path instead of being evicted in place. - **Closed Subagent** — A terminal subagent marked hidden from the dynamic system prompt (`closed` flag, persisted with the durable row) without deleting its session record, chain, or UI entry. Closing frees prompt space once the result is incorporated; closed records cannot be resumed by `follow_up_subagent`. - **Lazy Hydration** — On-demand materialization of a durable subagent record back into the runtime manager: evicted lean summaries (chain emptied by retention) and records persisted before the current app launch are rebuilt from the session's stored `subagentChains` when a lifecycle tool targets them. The stored row stays the authoritative complete copy; hydration untracks the retention FIFO and resets the persisted-revision entry so re-materialized records are neither deleted mid-use nor skipped by revision-gated checkpoints. diff --git a/electron/src/main/agents/manager.ts b/electron/src/main/agents/manager.ts index f8dddcc7..0b7b9c15 100644 --- a/electron/src/main/agents/manager.ts +++ b/electron/src/main/agents/manager.ts @@ -25,6 +25,7 @@ import type { SubagentRecord as DomainSubagentRecord } from '../../shared/types/ import { SubagentDeltaEventType, SubagentStatus, + summarizeSubagentRecord, type SubagentDeltaEvent, type SubagentLiveProjection, type SubagentTerminalState, @@ -420,7 +421,7 @@ export class SubagentManager { this._notify(); this._emitDelta(record, { type: SubagentDeltaEventType.SPAWNED, - record: this.toDomainRecord(record, { includeLiveTail: false }), + record: summarizeSubagentRecord(this.toDomainRecord(record, { includeLiveTail: false })), usage: record.usage, }); @@ -518,7 +519,7 @@ export class SubagentManager { this._notify(); this._emitDelta(record, { type: SubagentDeltaEventType.SPAWNED, - record: this.toDomainRecord(record, { includeLiveTail: false }), + record: summarizeSubagentRecord(this.toDomainRecord(record, { includeLiveTail: false })), usage: record.usage, }); if (this._runner) { @@ -529,7 +530,7 @@ export class SubagentManager { this._notify(); this._emitDelta(record, { type: SubagentDeltaEventType.SPAWNED, - record: this.toDomainRecord(record, { includeLiveTail: false }), + record: summarizeSubagentRecord(this.toDomainRecord(record, { includeLiveTail: false })), usage: record.usage, }); } @@ -1518,7 +1519,9 @@ export class SubagentManager { result: record.result, error: record.error, usage: record.usage, - terminalRecord: () => this.toDomainRecord(record, { includeLiveTail: true }), + terminalRecord: () => summarizeSubagentRecord( + this.toDomainRecord(record, { includeLiveTail: true }), + ), }); // A terminal subagent's owned background commands die with it (R9); the // scope id is the record id (see `_startRun`). The scope's foreground diff --git a/electron/src/main/agents/subagent-events.ts b/electron/src/main/agents/subagent-events.ts index ed85e8f2..3190bbc4 100644 --- a/electron/src/main/agents/subagent-events.ts +++ b/electron/src/main/agents/subagent-events.ts @@ -107,8 +107,8 @@ export interface SubagentDeltaBatcherOptions { * thinking appends per flush, caps each flush at a global event count and byte * budget, and defers (never drops) overflowing non-terminal deltas to the next * flush in order. `spawned`/`terminal` are budget-exempt and always flush. - * Delivers one `SubagentEvent` envelope per eligible session per flush; records - * ride only the `spawned`/`terminal` deltas the manager already built. + * Delivers one `SubagentEvent` envelope per eligible session per flush; + * lightweight summaries ride only the `spawned`/`terminal` deltas. */ export function createSubagentDeltaBatcher( deliver: (envelope: SubagentEvent) => void, diff --git a/electron/src/main/ipc/chat/events.ts b/electron/src/main/ipc/chat/events.ts index 25b21434..9b6f82c8 100644 --- a/electron/src/main/ipc/chat/events.ts +++ b/electron/src/main/ipc/chat/events.ts @@ -1,5 +1,6 @@ import { webContents as electronWebContents, type WebContents } from 'electron'; -import { IPC_CHANNELS } from '../../../shared/types/ipc'; +import { IPC_CHANNELS, type SessionUpdatedEvent } from '../../../shared/types/ipc'; +import type { Session } from '../../../shared/types/session'; import { getSessionManager } from '../../session/singleton'; import type { ActiveAgent, ChatStatePayload } from './state'; @@ -16,7 +17,7 @@ export function sendSessionEvent( source: WebContents | null, sessionId: string, channel: string, - payload: Record, + payload: object, ): void { const recipients = new Map(); const addIfSelected = (candidate: WebContents): void => { @@ -108,12 +109,30 @@ export function sendChatState( sendTurnEvent(webContents, active, IPC_CHANNELS.CHAT_STATE, payload); } +/** Build the bounded renderer patch for the chain changed by this write. */ +export function buildSessionUpdatedEvent( + session: Session, + chainId: string | null = session.activeChainId, +): SessionUpdatedEvent | null { + const chain = chainId + ? session.chains.find((candidate) => candidate.id === chainId) + : session.chains.at(-1); + if (!chain) return null; + return { + sessionId: session.id, + chain, + activeChainId: session.activeChainId, + updatedAt: session.updatedAt, + }; +} + /** Notify renderer of live session (multi-chain) state after startChain. */ export function emitSessionUpdated(webContents: WebContents, sessionId: string): void { try { const session = getSessionManager().getSession(sessionId); - if (session) { - sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, { session }); + const update = session ? buildSessionUpdatedEvent(session) : null; + if (update) { + sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, update); } } catch { // non-fatal diff --git a/electron/src/main/ipc/chat/persist.ts b/electron/src/main/ipc/chat/persist.ts index 76079d55..5eecab8b 100644 --- a/electron/src/main/ipc/chat/persist.ts +++ b/electron/src/main/ipc/chat/persist.ts @@ -14,7 +14,7 @@ import { makeThinkingMessage, } from '../../llm/message-factories'; import { activeAgents, pendingCheckpoints, type ActiveAgent } from './state'; -import { sendSessionEvent, webContentsForWindowId } from './events'; +import { buildSessionUpdatedEvent, sendSessionEvent, webContentsForWindowId } from './events'; import { textSegmentIdAtOffset } from './snapshot'; export function attachUsageToLatestAssistant(messages: Message[], usage: Usage): boolean { @@ -151,12 +151,13 @@ export function checkpointActiveTurn(agent: ActiveAgent, context: AgentContext): entry?.messages ?? messages, sessionId, ); - if (updated) { + const update = updated ? buildSessionUpdatedEvent(updated) : null; + if (update) { sendSessionEvent( webContentsForWindowId(active.windowId), sessionId, IPC_CHANNELS.SESSION_UPDATED, - { session: updated }, + update, ); } } catch (err) { @@ -203,8 +204,9 @@ export function persistTurnConversation( agentType: agent.type, agentTier: agent.tier, }, sessionId); - if (updated && webContents) { - sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, { session: updated }); + const update = updated ? buildSessionUpdatedEvent(updated, null) : null; + if (update && webContents) { + sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, update); } } catch (err) { console.debug('Failed to persist chat chain (non-fatal):', err); diff --git a/electron/src/main/ipc/payload-schemas.ts b/electron/src/main/ipc/payload-schemas.ts index f8cbd445..1403075f 100644 --- a/electron/src/main/ipc/payload-schemas.ts +++ b/electron/src/main/ipc/payload-schemas.ts @@ -38,6 +38,11 @@ export const subagentSnapshotSchema = z.object({ sessionId: z.string().uuid(), }).strict(); +export const subagentDetailSchema = z.object({ + sessionId: z.string().uuid(), + subagentId: z.string().min(1), +}).strict(); + // ── Ask Question ──────────────────────────────────────────────────────────── export const askQuestionAnswerSchema = z.object({ diff --git a/electron/src/main/ipc/session.ts b/electron/src/main/ipc/session.ts index 68ff4746..394890c8 100644 --- a/electron/src/main/ipc/session.ts +++ b/electron/src/main/ipc/session.ts @@ -6,7 +6,7 @@ */ import { BrowserWindow, dialog, ipcMain } from 'electron'; import { IPC_CHANNELS } from '../../shared/types/ipc'; -import { flattenSessionMessages } from '../../shared/types/session'; +import { flattenSessionMessages, sessionForRenderer } from '../../shared/types/session'; import type { ModelSelection } from '../../shared/types/provider'; import { getSessionManager, @@ -76,7 +76,7 @@ export { resolveWindowWorkspace, }; -export { flattenSessionMessages }; +export { flattenSessionMessages, sessionForRenderer }; export { takeDraftReasoningOverride } from '../session/draft-reasoning'; @@ -234,7 +234,8 @@ export function registerSessionIPC(): void { // Read-only peek (todos / subagents refresh) — do not switch or reseed. if (!activate) { - return manager.load(id); + const session = manager.load(id); + return session ? sessionForRenderer(session) : null; } const releasedDraftCwd = getDraftCwd(windowId); @@ -274,7 +275,7 @@ export function registerSessionIPC(): void { const workspace = resolveWindowWorkspace(windowId); emitWorkspaceChanged(event.sender, workspace); - return session; + return session ? sessionForRenderer(session) : null; }); // session:open — activate a session and return its full view payload in one @@ -324,7 +325,12 @@ export function registerSessionIPC(): void { const { getLiveChatSnapshot } = await import('./chat.js'); const live = getLiveChatSnapshot(id); - return { session, messages, live, workspace }; + return { + session: session ? sessionForRenderer(session) : null, + messages, + live, + workspace, + }; }); // session:create — eagerly create + activate a session (writes to disk). diff --git a/electron/src/main/ipc/subagents.ts b/electron/src/main/ipc/subagents.ts index 51b10c16..f0dfa02c 100644 --- a/electron/src/main/ipc/subagents.ts +++ b/electron/src/main/ipc/subagents.ts @@ -1,10 +1,15 @@ /** Session-affine subagent snapshot and live projection IPC. */ import { ipcMain } from 'electron'; -import { IPC_CHANNELS, type SubagentSnapshot } from '../../shared/types/ipc'; +import { + IPC_CHANNELS, + type SubagentDetailResult, + type SubagentSnapshot, +} from '../../shared/types/ipc'; import type { SubagentRecord as DomainSubagentRecord } from '../../shared/types/subagent'; +import { summarizeSubagentRecord } from '../../shared/types/subagent'; import { getSubagentManager } from '../tools'; import { getSessionManager } from '../session/singleton'; -import { subagentSnapshotSchema } from './payload-schemas'; +import { subagentDetailSchema, subagentSnapshotSchema } from './payload-schemas'; import { flushSubagentDeltas } from '../agents/subagent-events'; // Compatibility exports for existing IPC consumers. Event ownership lives in @@ -30,6 +35,16 @@ export function mergeSubagentRecords(stored: readonly DomainSubagentRecord[], ru return [...merged.values()]; } +export function selectSubagentDetailRecord( + subagentId: string, + stored: readonly DomainSubagentRecord[], + runtime: DomainSubagentRecord | null, +): DomainSubagentRecord | null { + return (runtime?.id === subagentId ? runtime : null) + ?? stored.find((record) => record.id === subagentId) + ?? null; +} + export function createSubagentSnapshot(sessionId: string): SubagentSnapshot { const manager = getSubagentManager(); const session = getSessionManager().getSession(sessionId); @@ -44,11 +59,26 @@ export function createSubagentSnapshot(sessionId: string): SubagentSnapshot { return { sessionId, sessionRevision: manager.getSessionRevision(sessionId), - records, + records: records.map(summarizeSubagentRecord), live: manager.getLiveProjections(sessionId), }; } +/** Materialize only the transcript explicitly selected in the renderer. */ +export function createSubagentDetail( + sessionId: string, + subagentId: string, +): SubagentDetailResult { + const manager = getSubagentManager(); + const candidate = manager.getRecord(subagentId); + const runtime = candidate?.sessionId === sessionId && !manager.isSummary(candidate.id) + ? manager.toDomainRecord(candidate, { includeLiveTail: true }) + : null; + const stored = getSessionManager().getSession(sessionId)?.subagentChains ?? []; + const record = selectSubagentDetailRecord(subagentId, stored, runtime); + return { sessionId, subagentId, record }; +} + let wired = false; export function registerSubagentIPC(): void { @@ -59,11 +89,17 @@ export function registerSubagentIPC(): void { if (!parsed.success) throw new Error(`Invalid subagent snapshot request: ${parsed.error.message}`); return createSubagentSnapshot(parsed.data.sessionId); }); + ipcMain.handle(IPC_CHANNELS.SUBAGENTS_DETAIL, (_event, raw: unknown) => { + const parsed = subagentDetailSchema.safeParse(raw); + if (!parsed.success) throw new Error(`Invalid subagent detail request: ${parsed.error.message}`); + return createSubagentDetail(parsed.data.sessionId, parsed.data.subagentId); + }); } export function unregisterSubagentIPC(): void { if (!wired) return; wired = false; ipcMain.removeHandler(IPC_CHANNELS.SUBAGENTS_SNAPSHOT); + ipcMain.removeHandler(IPC_CHANNELS.SUBAGENTS_DETAIL); flushSubagentDeltas(); } diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index a12eee64..d728b82c 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -83,6 +83,8 @@ import type { BgCommandChangedEvent, SubagentSnapshotRequest, SubagentSnapshot, + SubagentDetailRequest, + SubagentDetailResult, SubagentEvent, AskQuestionAnswerMessage, AskQuestionCancelMessage, @@ -129,6 +131,7 @@ import { chatToolCallUpdateEventSchema, sessionRenamedEventSchema, sessionCreatedEventSchema, + sessionUpdatedEventSchema, sessionWorkspaceChangedEventSchema, sessionTodosChangedEventSchema, sessionActivityChangedEventSchema, @@ -149,6 +152,7 @@ import { sessionServiceTierConfigResultSchema, chatSessionSnapshotSchema, subagentSnapshotSchema, + subagentDetailResultSchema, subagentEventSchema, subagentDeltaEventSchema, startupSnapshotSchema, @@ -184,6 +188,7 @@ const INVOKE_RESULT_SCHEMAS: Partial> = { [IPC_CHANNELS.CHAT_SEND]: chatSendResultSchema, [IPC_CHANNELS.CHAT_SNAPSHOT]: chatSessionSnapshotSchema, [IPC_CHANNELS.SUBAGENTS_SNAPSHOT]: subagentSnapshotSchema, + [IPC_CHANNELS.SUBAGENTS_DETAIL]: subagentDetailResultSchema, [IPC_CHANNELS.TOOL_EXECUTE]: toolExecuteResultSchema, [IPC_CHANNELS.BG_CMD_SNAPSHOT]: bgCommandSnapshotResultSchema, [IPC_CHANNELS.BG_CMD_LIST]: bgCommandListResultSchema, @@ -506,7 +511,7 @@ const orchidAPI: OrchidAPI = { onParsed(IPC_CHANNELS.SESSION_CREATED, sessionCreatedEventSchema, callback), onUpdated: (callback: (event: SessionUpdatedEvent) => void) => - onParsed(IPC_CHANNELS.SESSION_UPDATED, sessionCreatedEventSchema, callback), + onParsed(IPC_CHANNELS.SESSION_UPDATED, sessionUpdatedEventSchema, callback), onWorkspaceChanged: (callback: (event: SessionWorkspaceChangedEvent) => void) => onParsed(IPC_CHANNELS.SESSION_WORKSPACE_CHANGED, sessionWorkspaceChangedEventSchema, callback), @@ -538,6 +543,8 @@ const orchidAPI: OrchidAPI = { subagents: { snapshot: (request: SubagentSnapshotRequest) => invoke(IPC_CHANNELS.SUBAGENTS_SNAPSHOT, request), + detail: (request: SubagentDetailRequest) => + invoke(IPC_CHANNELS.SUBAGENTS_DETAIL, request), onEvent: (callback: (event: SubagentEvent) => void) => onSubagentEvent(callback), }, diff --git a/electron/src/renderer/components/ChatView.tsx b/electron/src/renderer/components/ChatView.tsx index 28b8871e..451cc3b2 100644 --- a/electron/src/renderer/components/ChatView.tsx +++ b/electron/src/renderer/components/ChatView.tsx @@ -341,15 +341,13 @@ export function ChatView({ isVisible = true, bootstrapConfig = null, onNotify, a (loadedSession: Session | null) => { if (!loadedSession) { chat.setMessages([]); - subagents.applyFromSession([]); todos.applyFromSession([]); return; } chat.setMessages(flattenSessionMessages(loadedSession)); - subagents.applyFromSession(loadedSession.subagentChains); todos.applyFromSession(loadedSession.todoStore.tasks); }, - [chat.setMessages, subagents.applyFromSession, todos.applyFromSession], + [chat.setMessages, todos.applyFromSession], ); const handleSessionSelect = useCallback( @@ -387,9 +385,8 @@ export function ChatView({ isVisible = true, bootstrapConfig = null, onNotify, a setDraftTabVisible(false); messageQueue.clearQueue(); - // Commit once: sidebar lists from the session, chat (messages + live) via - // hydrate. hydrateSnapshot owns the single message replace (no double set). - subagents.applyFromSession(result.session.subagentChains); + // Commit once: subagent summaries hydrate independently; chat messages + // and live state hydrate here without a duplicate message replace. todos.applyFromSession(result.session.todoStore.tasks); chat.hydrateSnapshot({ sessionId: result.session.id, @@ -397,7 +394,7 @@ export function ChatView({ isVisible = true, bootstrapConfig = null, onNotify, a live: result.live, }); }, - [session, chat.beginSessionSwitch, chat.hydrateSnapshot, subagents.applyFromSession, todos.applyFromSession, draftTabVisible, messageQueue.clearQueue], + [session, chat.beginSessionSwitch, chat.hydrateSnapshot, todos.applyFromSession, draftTabVisible, messageQueue.clearQueue], ); useEffect(() => { diff --git a/electron/src/renderer/components/Sidebar.tsx b/electron/src/renderer/components/Sidebar.tsx index cc801b71..853b452a 100644 --- a/electron/src/renderer/components/Sidebar.tsx +++ b/electron/src/renderer/components/Sidebar.tsx @@ -15,7 +15,7 @@ import { ContextGrid, contextPercent as getContextPercent } from './ContextGrid' import { contextUsedTokens } from '../../shared/usage'; import type { Message, Usage } from '../../shared/types/message'; import { TodoStatus } from '../../shared/types/todo'; -import type { SubagentRecord } from '../../shared/types/subagent'; +import type { SubagentSummary } from '../../shared/types/subagent'; import type { SubagentListState, SubagentDetail } from '../hooks/useSubagents'; import type { TodoListState } from '../hooks/useTodos'; import type { BackgroundCommandsState } from '../hooks/useBackgroundCommands'; @@ -344,20 +344,20 @@ function CollapseBlock({ // ── Subagents Section ──────────────────────────────────────────────────────── export interface SubagentStatusGroups { - running: readonly SubagentRecord[]; - queued: readonly SubagentRecord[]; - other: readonly SubagentRecord[]; + running: readonly SubagentSummary[]; + queued: readonly SubagentSummary[]; + other: readonly SubagentSummary[]; } /** Keep active work visible while putting terminal subagents behind a menu. */ export function partitionSubagentsByStatus( - agents: readonly SubagentRecord[], + agents: readonly SubagentSummary[], ): SubagentStatusGroups { const { running, queued, ended } = groupSubagents(agents); return { running, queued, other: ended }; } -export function countRunningSubagents(agents: readonly SubagentRecord[]): number { +export function countRunningSubagents(agents: readonly SubagentSummary[]): number { return partitionSubagentsByStatus(agents).running.length; } @@ -461,7 +461,7 @@ export function SubagentsSection({ } interface SubagentRowProps { - agent: SubagentRecord; + agent: SubagentSummary; selectedId: string | null; onSelect: (id: string | null) => void; getDetail: (id: string) => SubagentDetail | null; diff --git a/electron/src/renderer/components/SubagentView.tsx b/electron/src/renderer/components/SubagentView.tsx index d289cea0..755efdca 100644 --- a/electron/src/renderer/components/SubagentView.tsx +++ b/electron/src/renderer/components/SubagentView.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import type { SubagentRecord } from '../../shared/types/subagent'; +import type { SubagentSummary } from '../../shared/types/subagent'; import type { UseSubagentsReturn } from '../hooks/useSubagents'; import { formatUsageSummary } from '../utils/format-usage'; import { SubagentTranscript } from './SubagentTranscript'; @@ -37,7 +37,7 @@ export function keepSubagentRowSelected(currentId: string | null, rowId: string) export const formatSubagentUsage = formatUsageSummary; -function statusTone(status: SubagentRecord['status']): 'neutral' | 'warning' | 'success' | 'error' | 'info' { +function statusTone(status: SubagentSummary['status']): 'neutral' | 'warning' | 'success' | 'error' | 'info' { if (status === 'running') return 'warning'; if (status === 'pending' || status === 'queued') return 'neutral'; if (status === 'completed') return 'success'; @@ -45,7 +45,7 @@ function statusTone(status: SubagentRecord['status']): 'neutral' | 'warning' | ' return 'info'; } -function statusLabel(status: SubagentRecord['status']): string { +function statusLabel(status: SubagentSummary['status']): string { return status === 'completed' ? 'completed' : status; } @@ -55,7 +55,7 @@ function SubagentRow({ detail, onSelect, }: { - record: SubagentRecord; + record: SubagentSummary; selected: boolean; detail: ReturnType; onSelect: () => void; @@ -111,7 +111,7 @@ export function SubagentView({ subagents, onBackToChat, openRequest }: SubagentV setNarrowDetail(true); }; - const renderGroup = (title: string, group: readonly SubagentRecord[]) => ( + const renderGroup = (title: string, group: readonly SubagentSummary[]) => (
{title}} /> {group.length === 0 ? ( @@ -183,7 +183,23 @@ export function SubagentView({ subagents, onBackToChat, openRequest }: SubagentV ) : null}
- + {subagents.transcript.status === 'ready' ? ( + + ) : subagents.transcript.status === 'error' ? ( + void subagents.retryTranscript()}>Retry} + /> + ) : subagents.transcript.status === 'unavailable' ? ( + + ) : ( + + )}
) : ( diff --git a/electron/src/renderer/hooks/useSession.ts b/electron/src/renderer/hooks/useSession.ts index ea3c2ecf..73393502 100644 --- a/electron/src/renderer/hooks/useSession.ts +++ b/electron/src/renderer/hooks/useSession.ts @@ -252,10 +252,17 @@ function ensureBootstrapped(): void { setActiveSession((prev) => { // Only update when the same session is still active. Never resurrect // a session after New Chat/draft (prev === null) from a late event. - if (prev?.id === event.session.id) { - return event.session; - } - return prev; + if (prev?.id !== event.sessionId) return prev; + const chainIndex = prev.chains.findIndex((chain) => chain.id === event.chain.id); + const chains = chainIndex < 0 + ? [...prev.chains, event.chain] + : prev.chains.map((chain, index) => index === chainIndex ? event.chain : chain); + return { + ...prev, + chains, + activeChainId: event.activeChainId, + updatedAt: event.updatedAt, + }; }); }), ); diff --git a/electron/src/renderer/hooks/useSubagents.ts b/electron/src/renderer/hooks/useSubagents.ts index 31c2795d..126588b9 100644 --- a/electron/src/renderer/hooks/useSubagents.ts +++ b/electron/src/renderer/hooks/useSubagents.ts @@ -1,11 +1,15 @@ /** Session-affine subagent snapshot/live state for the inspector and view. */ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import type { Usage } from '../../shared/types/message'; -import type { SubagentLiveProjection, SubagentRecord, SubagentStatus } from '../../shared/types/subagent'; +import type { + SubagentLiveProjection, + SubagentRecord, + SubagentStatus, + SubagentSummary, +} from '../../shared/types/subagent'; import { deriveSubagentUsageSummary, EMPTY_SUBAGENT_USAGE_SUMMARY, - sumSubagentUsage, type SubagentUsageSummary, } from '../../shared/usage'; import { @@ -17,7 +21,6 @@ import { failSubagentSnapshot, groupSubagents, isSubagentSnapshotAffine, - replaceSubagentRecords, resolveSubagentSelection, seedSubagentSnapshot, type SubagentStreamState, @@ -26,7 +29,14 @@ import { export type SubagentListState = | { status: 'loading' } | { status: 'empty' } - | { status: 'ready'; subagents: readonly SubagentRecord[] } + | { status: 'ready'; subagents: readonly SubagentSummary[] } + | { status: 'error'; error: string }; + +export type SubagentTranscriptState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; record: SubagentRecord } + | { status: 'unavailable' } | { status: 'error'; error: string }; export interface SubagentDetail { @@ -37,11 +47,11 @@ export interface SubagentDetail { export interface UseSubagentsReturn { state: SubagentListState; - subagents: readonly SubagentRecord[]; + subagents: readonly SubagentSummary[]; groups: { - queued: readonly SubagentRecord[]; - running: readonly SubagentRecord[]; - ended: readonly SubagentRecord[]; + queued: readonly SubagentSummary[]; + running: readonly SubagentSummary[]; + ended: readonly SubagentSummary[]; }; totalUsage: Usage | null; usageByParentChain: ReadonlyMap; @@ -54,10 +64,11 @@ export interface UseSubagentsReturn { refresh: () => Promise; retry: () => Promise; isRetrying: boolean; - applyFromSession: (subagents: readonly SubagentRecord[]) => void; selectedId: string | null; select: (id: string | null) => void; getDetail: (id: string) => SubagentDetail | null; + transcript: SubagentTranscriptState; + retryTranscript: () => Promise; live: ReadonlyMap; getLive: (id: string) => SubagentLiveProjection | null; } @@ -77,9 +88,9 @@ function formatAgentRole(value: string): string { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function displayAgentType(record: SubagentRecord): string { +function displayAgentType(record: SubagentSummary): string { const persistedType = record.agent_type.trim(); - const chainRole = record.chain.agentName?.trim() ?? ''; + const chainRole = record.agentRole.trim(); const role = persistedType && persistedType !== 'subagent' ? persistedType : chainRole && chainRole !== 'general' @@ -89,7 +100,7 @@ function displayAgentType(record: SubagentRecord): string { } export function buildSubagentDetail( - record: SubagentRecord, + record: SubagentSummary, now: number, live: SubagentLiveProjection | null = null, ): SubagentDetail { @@ -104,8 +115,8 @@ export function buildSubagentDetail( id: record.id, name: record.agent_name || 'Subagent', type: displayAgentType(record), tier: record.agent_tier || 'bloom', state, task: record.task || '', elapsed: formatElapsed(Math.max(0, end - start)), isRunning: running, - result: record.result, error: record.error, - usage: live?.usage ?? sumSubagentUsage(record), + result: live?.result ?? null, error: live?.error ?? null, + usage: live?.usage ?? record.usage, }; } @@ -123,7 +134,9 @@ export function useSubagents(activeSessionId: string | null): UseSubagentsReturn const [requestedId, setRequestedId] = useState(null); const [tick, setTick] = useState(0); const [isRetrying, setIsRetrying] = useState(false); + const [transcript, setTranscript] = useState({ status: 'idle' }); const requestRef = useRef(0); + const transcriptRequestRef = useRef(0); const requestedRef = useRef(null); const selectedSessionRef = useRef(null); const hydrationBufferBytesRef = useRef(DEFAULT_HYDRATION_BUFFER_BYTES); @@ -244,10 +257,6 @@ export function useSubagents(activeSessionId: string | null): UseSubagentsReturn await hydrate(activeRef.current, true); }, [commit, hydrate]); - const applyFromSession = useCallback((records: readonly SubagentRecord[]) => { - commit(replaceSubagentRecords(streamRef.current, records)); - }, [commit]); - const select = useCallback((id: string | null) => { setRequestedId(id); setSelectedId((previous) => previous === id ? null : id); @@ -255,6 +264,57 @@ export function useSubagents(activeSessionId: string | null): UseSubagentsReturn }, []); const subagents = current.records; + const selectedSummary = selectedId + ? subagents.find((record) => record.id === selectedId) ?? null + : null; + + const loadTranscript = useCallback(async (sessionId: string, subagentId: string): Promise => { + const request = ++transcriptRequestRef.current; + if (!window.orchid?.subagents?.detail) { + setTranscript({ status: 'error', error: 'Subagent transcript is unavailable' }); + return; + } + setTranscript({ status: 'loading' }); + try { + const result = await window.orchid.subagents.detail({ sessionId, subagentId }); + if ( + request !== transcriptRequestRef.current || + activeRef.current !== result.sessionId || + result.subagentId !== subagentId + ) return; + setTranscript(result.record + ? { status: 'ready', record: result.record } + : { status: 'unavailable' }); + } catch (error) { + if (request === transcriptRequestRef.current && activeRef.current === sessionId) { + setTranscript({ + status: 'error', + error: error instanceof Error ? error.message : String(error), + }); + } + } + }, []); + + useEffect(() => { + if (!activeSessionId || !selectedId || !selectedSummary) { + transcriptRequestRef.current += 1; + setTranscript({ status: 'idle' }); + return; + } + void loadTranscript(activeSessionId, selectedId); + }, [ + activeSessionId, + loadTranscript, + selectedId, + selectedSummary?.end_time, + selectedSummary?.status, + ]); + + const retryTranscript = useCallback(async () => { + if (!activeRef.current || !selectedId) return; + await loadTranscript(activeRef.current, selectedId); + }, [loadTranscript, selectedId]); + const state = listState(current); const groups = useMemo(() => groupSubagents(subagents), [subagents]); const usageSummaryRef = useRef(EMPTY_SUBAGENT_USAGE_SUMMARY); @@ -275,6 +335,6 @@ export function useSubagents(activeSessionId: string | null): UseSubagentsReturn const getLive = useCallback((id: string) => current.live.get(id) ?? null, [current.live]); return { state, subagents, groups, totalUsage, usageByParentChain, usageSummary, refresh, retry, isRetrying, - applyFromSession, selectedId, select, getDetail, live: current.live, getLive, + selectedId, select, getDetail, transcript, retryTranscript, live: current.live, getLive, }; } diff --git a/electron/src/renderer/utils/subagent-stream.ts b/electron/src/renderer/utils/subagent-stream.ts index fe7732c0..e9128a7b 100644 --- a/electron/src/renderer/utils/subagent-stream.ts +++ b/electron/src/renderer/utils/subagent-stream.ts @@ -4,7 +4,7 @@ import type { SubagentDeltaEvent, SubagentLiveProjection, SubagentLiveSegment, - SubagentRecord, + SubagentSummary, SubagentSpawnedEvent, SubagentStatus, SubagentStatusChangedEvent, @@ -18,7 +18,7 @@ export type SubagentHydrationState = 'loading' | 'ready' | 'empty' | 'error'; export interface SubagentStreamState { readonly sessionId: string | null; readonly hydration: SubagentHydrationState; - readonly records: readonly SubagentRecord[]; + readonly records: readonly SubagentSummary[]; readonly live: ReadonlyMap; readonly highWater: ReadonlyMap; readonly runs: ReadonlyMap; @@ -47,7 +47,7 @@ const isRunning = (status: SubagentStatus): boolean => // Timestamp-descending only: sort is stable, so equal timestamps keep input // order (spawn/insertion order) — the ordering callers like the Sidebar // partition expect when delegating their bucketing to groupSubagents. -function compareNewest(a: SubagentRecord, b: SubagentRecord): number { +function compareNewest(a: SubagentSummary, b: SubagentSummary): number { return Date.parse(b.start_time) - Date.parse(a.start_time); } @@ -124,10 +124,10 @@ export interface SubagentSelectionOptions { existingSessionId?: string | null; } -export function groupSubagents(records: readonly SubagentRecord[]): { - queued: readonly SubagentRecord[]; - running: readonly SubagentRecord[]; - ended: readonly SubagentRecord[]; +export function groupSubagents(records: readonly SubagentSummary[]): { + queued: readonly SubagentSummary[]; + running: readonly SubagentSummary[]; + ended: readonly SubagentSummary[]; } { const sorted = [...records].sort(compareNewest); return { @@ -138,7 +138,7 @@ export function groupSubagents(records: readonly SubagentRecord[]): { } export function resolveSubagentSelection( - records: readonly SubagentRecord[], + records: readonly SubagentSummary[], options: SubagentSelectionOptions, ): string | null { const ids = new Set(records.map((record) => record.id)); @@ -476,18 +476,3 @@ export function seedSubagentSnapshot( export function failSubagentSnapshot(state: SubagentStreamState, error: string): SubagentStreamState { return { ...state, hydration: 'error', error, buffered: [], bufferedBytes: 0 }; } - -/** Apply session-loaded durable records without disturbing a live projection. */ -export function replaceSubagentRecords( - state: SubagentStreamState, - records: readonly SubagentRecord[], -): SubagentStreamState { - // ChatView may hand us the session-load result while the richer snapshot is - // still in flight. Keep loading affinity intact so the response can seed - // high-water marks and replay buffered events over these durable records. - return { - ...state, - records: [...records].sort(compareNewest), - hydration: state.hydration === 'loading' ? 'loading' : records.length ? 'ready' : 'empty', - }; -} diff --git a/electron/src/shared/types/ipc-schemas.ts b/electron/src/shared/types/ipc-schemas.ts index d3a54d00..b72c8541 100644 --- a/electron/src/shared/types/ipc-schemas.ts +++ b/electron/src/shared/types/ipc-schemas.ts @@ -100,6 +100,17 @@ const messageSchema = z.object({ tool_result: canonicalToolResultSchema.nullable(), }).strict(); +/** + * Minimum durable chain shape required by renderer consumers. Remaining chain + * metadata stays passthrough so this boundary does not duplicate the domain + * schema, while `id`/`sessionId`/`messages` can never disappear silently. + */ +const ipcChainEnvelopeSchema = z.object({ + id: z.string().min(1), + sessionId: z.string(), + messages: z.array(messageSchema), +}).passthrough(); + // ── Chat events ────────────────────────────────────────────────────────────── export const chatChunkEventSchema = chatEventIdentitySchema.extend({ @@ -204,6 +215,14 @@ export const sessionCreatedEventSchema = z.object({ draftGeneration: z.number().optional(), }); +/** The patch envelope is strict and its changed chain is structurally present. */ +export const sessionUpdatedEventSchema = z.object({ + sessionId: z.string().min(1), + chain: ipcChainEnvelopeSchema, + activeChainId: z.string().nullable(), + updatedAt: z.string(), +}).strict(); + export const trustStateSchema = z.enum(['trusted', 'untrusted', 'changed']); export const workspaceInfoSchema = z.object({ @@ -531,14 +550,25 @@ export const ipcSubagentRecordSchema = z.object({ result: z.string().nullable(), error: z.string().nullable(), parentChainIndex: z.number().int().nullable(), reasoning_effort: z.union([z.string(), z.number()]).optional(), closed: z.boolean().default(false), - chain: z.unknown(), + chain: ipcChainEnvelopeSchema, }); +export const ipcSubagentSummarySchema = z.object({ + id: z.string(), agent_name: z.string(), agent_type: z.string(), agent_tier: z.string(), + agentRole: z.string(), task: z.string(), status: subagentStatusSchema, + chain_id: z.string(), start_time: z.string(), end_time: z.string().nullable(), + parentChainIndex: z.number().int().nullable(), usage: usageSchema.nullable(), +}).strict(); export const subagentSnapshotSchema = z.object({ sessionId: z.string().uuid(), sessionRevision: z.number().int().nonnegative(), - records: z.array(ipcSubagentRecordSchema), + records: z.array(ipcSubagentSummarySchema), live: z.array(subagentLiveProjectionSchema), }); +export const subagentDetailResultSchema = z.object({ + sessionId: z.string().uuid(), + subagentId: z.string(), + record: ipcSubagentRecordSchema.nullable(), +}).strict(); // ── Subagent live delta events ─────────────────────────────────────────────── @@ -550,7 +580,7 @@ const subagentDeltaBaseSchema = z.object({ sessionRevision: z.number().int().nonnegative(), }); export const subagentSpawnedEventSchema = subagentDeltaBaseSchema.extend({ - type: z.literal('spawned'), record: ipcSubagentRecordSchema, usage: usageSchema.nullable(), + type: z.literal('spawned'), record: ipcSubagentSummarySchema, usage: usageSchema.nullable(), }); export const subagentTextDeltaEventSchema = subagentDeltaBaseSchema.extend({ type: z.literal('text_delta'), segmentId: z.string(), append: z.string(), @@ -575,7 +605,7 @@ export const subagentUsageEventSchema = subagentDeltaBaseSchema.extend({ type: z.literal('usage'), usage: usageSchema, }); export const subagentTerminalEventSchema = subagentDeltaBaseSchema.extend({ - type: z.literal('terminal'), record: ipcSubagentRecordSchema, + type: z.literal('terminal'), record: ipcSubagentSummarySchema, state: z.enum(['completed', 'failed', 'interrupted']), usage: usageSchema.nullable(), }); export const subagentDeltaEventSchema = z.discriminatedUnion('type', [ diff --git a/electron/src/shared/types/ipc.ts b/electron/src/shared/types/ipc.ts index ac7d0350..8f7b86a8 100644 --- a/electron/src/shared/types/ipc.ts +++ b/electron/src/shared/types/ipc.ts @@ -8,13 +8,19 @@ */ import type { Session } from './session'; +import type { Chain } from './chain'; import type { Message, Usage } from './message'; import type { CanonicalToolResult, TerminalToolResultStatus, ToolExecutionResult, } from './tool-result'; -import type { SubagentDeltaEvent, SubagentLiveProjection, SubagentRecord } from './subagent'; +import type { + SubagentDeltaEvent, + SubagentLiveProjection, + SubagentRecord, + SubagentSummary, +} from './subagent'; import type { RiskClass, ToolScope } from './permission'; import type { CustomConnectionModel, @@ -200,13 +206,22 @@ export interface SubagentSnapshot { * renderer rejects snapshots below its recorded revision floor. */ sessionRevision: number; - records: SubagentRecord[]; + records: SubagentSummary[]; live: SubagentLiveProjection[]; } +export interface SubagentDetailRequest { + sessionId: string; + subagentId: string; +} +export interface SubagentDetailResult { + sessionId: string; + subagentId: string; + record: SubagentRecord | null; +} /** * Unit of SUBAGENTS_EVENT delivery: one budgeted flush of typed live deltas - * for a single session. Records ride only `spawned`/`terminal` deltas, so - * projection-only batches keep renderer record identity stable. + * for a single session. Summaries ride only `spawned`/`terminal` deltas, so + * projection-only batches keep renderer row identity stable. */ export interface SubagentEvent { sessionId: string; @@ -825,10 +840,17 @@ export interface SessionCreatedEvent { } /** - * Fired when the active session's multi-chain state changes (start/finish turn). - * Same payload shape as SessionCreatedEvent so the renderer can refresh chains. + * Narrow durable patch emitted when one main-agent chain changes. + * + * Deliberately excludes `subagentChains` and every other unchanged session + * field so a checkpoint cannot clone the full session graph into a renderer. */ -export type SessionUpdatedEvent = SessionCreatedEvent; +export interface SessionUpdatedEvent { + sessionId: string; + chain: Chain; + activeChainId: string | null; + updatedAt: string; +} export interface SessionChangeModelMessage { id: string; @@ -1342,6 +1364,8 @@ export interface OrchidAPI { subagents: { snapshot: (request: SubagentSnapshotRequest) => Promise; + /** Fetch the full durable transcript for the currently selected row. */ + detail: (request: SubagentDetailRequest) => Promise; /** Batched subagent live deltas for the window's active session. */ onEvent: (callback: (event: SubagentEvent) => void) => () => void; }; @@ -1466,6 +1490,7 @@ export const IPC_CHANNELS = { CHAT_TOOL_CALL_UPDATE: 'chat:tool_call_update', SUBAGENTS_SNAPSHOT: 'subagents:snapshot', + SUBAGENTS_DETAIL: 'subagents:detail', SUBAGENTS_EVENT: 'subagents:event', // Config @@ -1633,6 +1658,7 @@ export const ALLOWED_INVOKE_CHANNELS = [ IPC_CHANNELS.CHAT_STOP, IPC_CHANNELS.CHAT_SNAPSHOT, IPC_CHANNELS.SUBAGENTS_SNAPSHOT, + IPC_CHANNELS.SUBAGENTS_DETAIL, IPC_CHANNELS.CONFIG_GET, IPC_CHANNELS.CONFIG_SAVE, IPC_CHANNELS.CONFIG_PERMISSION_SCOPES, diff --git a/electron/src/shared/types/session.ts b/electron/src/shared/types/session.ts index 7f9c1f24..dedaca21 100644 --- a/electron/src/shared/types/session.ts +++ b/electron/src/shared/types/session.ts @@ -51,6 +51,16 @@ export interface Session { readonly permissionMode: PermissionMode | null; } +/** + * Session DTO for renderer navigation. Historical subagent transcripts are + * fetched through the selected-subagent detail endpoint instead. + */ +export function sessionForRenderer(session: Session): Session { + return session.subagentChains.length === 0 + ? session + : { ...session, subagentChains: [] }; +} + // ── Storage dict ──────────────────────────────────────────────────────────── export interface SessionStorageDict { diff --git a/electron/src/shared/types/subagent.ts b/electron/src/shared/types/subagent.ts index a7b0d690..3ad64d79 100644 --- a/electron/src/shared/types/subagent.ts +++ b/electron/src/shared/types/subagent.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import type { Chain } from './chain'; import type { Usage } from './message'; +import { sumMessageUsages } from '../usage'; import type { CanonicalToolResult, TerminalToolResultStatus, @@ -104,12 +105,12 @@ export interface SubagentDeltaEventBase { } /** - * Durable record seed emitted once at spawn. One of only two record carriers - * (the other is `terminal`), so projection-only deltas never rebuild records. + * Lightweight row seed emitted once at spawn. One of only two summary + * carriers (the other is `terminal`), so transcript data stays off this wire. */ export interface SubagentSpawnedEvent extends SubagentDeltaEventBase { readonly type: typeof SubagentDeltaEventType.SPAWNED; - readonly record: SubagentRecord; + readonly record: SubagentSummary; readonly usage: Usage | null; } @@ -181,12 +182,12 @@ export interface SubagentUsageEvent extends SubagentDeltaEventBase { } /** - * Authoritative durable handoff emitted once when the run settles. Carries - * the final durable record so no post-terminal snapshot is required. + * Authoritative list handoff emitted once when the run settles. Carries the + * final summary so no post-terminal snapshot is required. */ export interface SubagentTerminalEvent extends SubagentDeltaEventBase { readonly type: typeof SubagentDeltaEventType.TERMINAL; - readonly record: SubagentRecord; + readonly record: SubagentSummary; readonly state: SubagentTerminalState; readonly usage: Usage | null; } @@ -236,6 +237,43 @@ export interface SubagentRecord { readonly chain: Chain; } +/** + * Lightweight renderer list row. Full transcripts remain in SubagentRecord + * and cross IPC only through the selected-record detail request. + */ +export interface SubagentSummary { + readonly id: string; + readonly agent_name: string; + readonly agent_type: string; + readonly agent_tier: string; + readonly agentRole: string; + readonly task: string; + readonly status: SubagentStatus; + readonly chain_id: string; + readonly start_time: string; + readonly end_time: string | null; + readonly parentChainIndex: number | null; + readonly usage: Usage | null; +} + +/** Collapse a durable record into the bounded list/delta wire representation. */ +export function summarizeSubagentRecord(record: SubagentRecord): SubagentSummary { + return { + id: record.id, + agent_name: record.agent_name, + agent_type: record.agent_type, + agent_tier: record.agent_tier, + agentRole: record.chain.agentName, + task: record.task, + status: record.status, + chain_id: record.chain_id, + start_time: record.start_time, + end_time: record.end_time, + parentChainIndex: record.parentChainIndex, + usage: sumMessageUsages(record.chain.messages), + }; +} + // ── Wire size estimation ──────────────────────────────────────────────────── /** @@ -245,13 +283,9 @@ export interface SubagentRecord { */ const TOOL_RESULT_PAYLOAD_PROXY_BYTES = 256; -const CHAIN_MESSAGE_PROXY_BYTES = 128; - -function estimateRecordBytes(record: SubagentRecord): number { +function estimateSummaryBytes(record: SubagentSummary): number { return record.id.length + record.agent_name.length + record.agent_type.length - + record.agent_tier.length + record.task.length - + (record.result?.length ?? 0) + (record.error?.length ?? 0) - + (record.chain?.messages?.length ?? 0) * CHAIN_MESSAGE_PROXY_BYTES; + + record.agent_tier.length + record.agentRole.length + record.task.length; } /** @@ -284,7 +318,7 @@ export function estimateDeltaBytes(event: SubagentDeltaEvent): number { break; case 'spawned': case 'terminal': - bytes += estimateRecordBytes(event.record); + bytes += estimateSummaryBytes(event.record); break; case 'status_changed': bytes += event.status.length; diff --git a/electron/src/shared/usage.ts b/electron/src/shared/usage.ts index 528c40ec..b88b7eec 100644 --- a/electron/src/shared/usage.ts +++ b/electron/src/shared/usage.ts @@ -97,11 +97,14 @@ export function latestUsageFromMessages( */ export interface SubagentUsageSource { readonly parentChainIndex?: number | null; + /** Pre-aggregated usage carried by lightweight subagent summaries. */ + readonly usage?: Usage | null; readonly chain?: { readonly messages?: readonly Message[] } | null; } /** Sum usage from one subagent's chain messages. */ export function sumSubagentUsage(subagent: SubagentUsageSource): Usage | null { + if ('usage' in subagent) return subagent.usage ?? null; const messages = subagent.chain?.messages; if (!messages || messages.length === 0) return null; return sumMessageUsages(messages); diff --git a/electron/tests/unit/chat-ipc.test.ts b/electron/tests/unit/chat-ipc.test.ts index 61948154..196ac08e 100644 --- a/electron/tests/unit/chat-ipc.test.ts +++ b/electron/tests/unit/chat-ipc.test.ts @@ -1751,12 +1751,20 @@ describe('chat IPC provider gates', () => { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', name: 'Investigate Session Naming', }); - expect(channelEvents(send, IPC_CHANNELS.SESSION_UPDATED).at(-1)?.[1]).toMatchObject({ - session: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', activeChainId: null }, + const sourceUpdate = channelEvents(send, IPC_CHANNELS.SESSION_UPDATED).at(-1)?.[1]; + const peerUpdate = channelEvents(sameSession.send, IPC_CHANNELS.SESSION_UPDATED).at(-1)?.[1]; + expect(sourceUpdate).toMatchObject({ + sessionId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + activeChainId: null, + chain: { id: 'chain-1', status: 'completed' }, }); - expect(channelEvents(sameSession.send, IPC_CHANNELS.SESSION_UPDATED).at(-1)?.[1]).toMatchObject({ - session: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', activeChainId: null }, + expect(peerUpdate).toMatchObject({ + sessionId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + activeChainId: null, + chain: { id: 'chain-1', status: 'completed' }, }); + expect(sourceUpdate).not.toHaveProperty('session'); + expect(sourceUpdate).not.toHaveProperty('subagentChains'); expect(channelEvents(sameSession.send, IPC_CHANNELS.SESSION_RENAMED).at(-1)?.[1]).toEqual({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', name: 'Investigate Session Naming', diff --git a/electron/tests/unit/preload-validation.test.ts b/electron/tests/unit/preload-validation.test.ts index 87ccd50e..7b9a1e22 100644 --- a/electron/tests/unit/preload-validation.test.ts +++ b/electron/tests/unit/preload-validation.test.ts @@ -67,15 +67,14 @@ function terminalEvent(sequence: number): Record { agent_name: 'Explorer', agent_type: 'explorer', agent_tier: 'bloom', + agentRole: 'explorer', task: 'Inspect the project', status: 'completed', chain_id: 'chain-1', start_time: '2026-01-01T00:00:00.000Z', end_time: '2026-01-01T00:00:05.000Z', - result: 'done', - error: null, parentChainIndex: null, - chain: { messages: [] }, + usage: null, }, state: 'completed', usage: null, diff --git a/electron/tests/unit/session-persistence.test.ts b/electron/tests/unit/session-persistence.test.ts index 4ca4cff5..8772b65e 100644 --- a/electron/tests/unit/session-persistence.test.ts +++ b/electron/tests/unit/session-persistence.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { Session } from '../../src/shared/types/session'; +import { sessionForRenderer, type Session } from '../../src/shared/types/session'; import type { Message } from '../../src/shared/types/message'; import type { Chain } from '../../src/shared/types/chain'; import { ChainStatus } from '../../src/shared/types/chain'; @@ -174,6 +174,20 @@ afterEach(() => { // Save → load round-trip // =========================================================================== +describe('renderer session projection', () => { + it('omits subagent transcripts without mutating the domain session', () => { + const session = makeSession({ id: randomUUID() }); + const transcript = makeSubagentRecord(session.id, { id: 'selected-lazily' }); + const domain = { ...session, subagentChains: [transcript] }; + + const renderer = sessionForRenderer(domain); + + expect(renderer.subagentChains).toEqual([]); + expect(domain.subagentChains).toEqual([transcript]); + expect(renderer.chains).toBe(domain.chains); + }); +}); + describe('saveSession → loadSession round-trip', () => { it('preserves canonical tool facts through session storage', () => { const sessionId = 'a1010101-1010-4010-8010-101010101010'; diff --git a/electron/tests/unit/subagent-ipc.test.ts b/electron/tests/unit/subagent-ipc.test.ts index ffde58b4..cd2b086b 100644 --- a/electron/tests/unit/subagent-ipc.test.ts +++ b/electron/tests/unit/subagent-ipc.test.ts @@ -2,11 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ALLOWED_EVENT_CHANNELS, ALLOWED_INVOKE_CHANNELS, IPC_CHANNELS, type SubagentEvent } from '../../src/shared/types/ipc'; import { subagentDeltaEventSchema, + subagentDetailResultSchema, subagentEventSchema, + sessionUpdatedEventSchema, subagentSnapshotSchema, } from '../../src/shared/types/ipc-schemas'; import { SubagentDeltaEventType, type SubagentDeltaEvent } from '../../src/shared/types/subagent'; -import { subagentSnapshotSchema as requestSchema } from '../../src/main/ipc/payload-schemas'; +import { + subagentDetailSchema as detailRequestSchema, + subagentSnapshotSchema as requestSchema, +} from '../../src/main/ipc/payload-schemas'; import { createSubagentPersistenceScheduler, persistSubagentChains, @@ -16,6 +21,7 @@ import { createSubagentDeltaBatcher as createIpcSubagentDeltaBatcher, deliverSubagentDeltaEvent as deliverIpcSubagentDeltaEvent, mergeSubagentRecords, + selectSubagentDetailRecord, } from '../../src/main/ipc/subagents'; import { createSubagentDeltaBatcher, @@ -54,7 +60,15 @@ vi.mock('../../src/main/session/singleton', () => ({ const record = (id: string, status: string) => ({ id, agent_name: 'agent', agent_type: 'subagent', agent_tier: 'bloom', task: id, status, chain_id: id, start_time: new Date(0).toISOString(), end_time: null, - result: null, error: null, parentChainIndex: null, chain: {} as never, + result: null, error: null, parentChainIndex: null, + chain: { id, sessionId: session, messages: [] } as never, +}) as never; + +const summary = (id: string, status: string) => ({ + id, agent_name: 'agent', agent_type: 'subagent', agent_tier: 'bloom', + agentRole: 'general', task: id, status, chain_id: id, + start_time: new Date(0).toISOString(), end_time: null, + parentChainIndex: null, usage: null, }) as never; const deltaBase = { sessionId: session, subagentId: 'subagent-1', runId: uuid, sessionRevision: 0 }; @@ -65,7 +79,7 @@ const textDelta = (sequence: number, append: string, segmentId = 'seg-1'): Subag const terminalDelta = (sequence: number): SubagentDeltaEvent => ({ ...deltaBase, sequence, type: 'terminal', - record: record('subagent-1', 'completed'), state: 'completed', usage: null, + record: summary('subagent-1', 'completed'), state: 'completed', usage: null, }); describe('subagent IPC boundary', () => { @@ -82,11 +96,46 @@ describe('subagent IPC boundary', () => { expect(requestSchema.safeParse({ sessionId: uuid }).success).toBe(true); }); + it('requires a session-affine selected subagent detail request', () => { + expect(detailRequestSchema.safeParse({ sessionId: uuid, subagentId: 'subagent-1' }).success).toBe(true); + expect(detailRequestSchema.safeParse({ sessionId: 'bad', subagentId: 'subagent-1' }).success).toBe(false); + expect(detailRequestSchema.safeParse({ sessionId: uuid, subagentId: '' }).success).toBe(false); + expect(detailRequestSchema.safeParse({ sessionId: uuid, subagentId: 'subagent-1', extra: true }).success).toBe(false); + }); + + it('requires the changed chain in a strict session update patch', () => { + const update = { + sessionId: session, + chain: { id: 'chain-1', sessionId: session, messages: [] }, + activeChainId: null, + updatedAt: new Date(0).toISOString(), + }; + expect(sessionUpdatedEventSchema.safeParse(update).success).toBe(true); + expect(sessionUpdatedEventSchema.safeParse({ ...update, chain: undefined }).success).toBe(false); + expect(sessionUpdatedEventSchema.safeParse({ ...update, subagentChains: [] }).success).toBe(false); + }); + it('keeps the new invoke/event channels allowlisted exactly once', () => { expect(ALLOWED_INVOKE_CHANNELS.filter((channel) => channel === IPC_CHANNELS.SUBAGENTS_SNAPSHOT)).toHaveLength(1); + expect(ALLOWED_INVOKE_CHANNELS.filter((channel) => channel === IPC_CHANNELS.SUBAGENTS_DETAIL)).toHaveLength(1); expect(ALLOWED_EVENT_CHANNELS.filter((channel) => channel === IPC_CHANNELS.SUBAGENTS_EVENT)).toHaveLength(1); }); + it('validates one selected full record as a detail response', () => { + const result = { + sessionId: session, + subagentId: 'subagent-1', + record: record('subagent-1', 'completed'), + }; + expect(subagentDetailResultSchema.safeParse(result).success).toBe(true); + expect(subagentDetailResultSchema.safeParse({ ...result, subagentId: 'other', extra: true }).success).toBe(false); + expect(subagentDetailResultSchema.safeParse({ ...result, record: null }).success).toBe(true); + expect(subagentDetailResultSchema.safeParse({ + ...result, + record: { ...result.record, chain: undefined }, + }).success).toBe(false); + }); + it('accepts canonical terminal tool snapshots and rejects terminal string-only snapshots', () => { const canonical = createCanonicalToolResult('generic', { status: 'cancelled', @@ -141,6 +190,16 @@ describe('subagent IPC boundary', () => { expect(merged.find((item) => item.id === 'active-1')?.status).toBe('running'); }); + it('selects exactly one detail transcript with runtime precedence', () => { + const stored = [record('first', 'completed'), record('selected', 'completed')]; + const runtime = [{ ...record('selected', 'running'), task: 'runtime transcript' }]; + + const selected = selectSubagentDetailRecord('selected', stored, runtime[0]); + + expect(selected?.task).toBe('runtime transcript'); + expect(selectSubagentDetailRecord('missing', stored, runtime[0])).toBeNull(); + }); + it('targets batched delta envelopes only at non-destroyed windows owning the session', () => { const sent: unknown[] = []; const makeWindow = (id: string, destroyed = false) => ({ @@ -534,7 +593,7 @@ describe('subagent delta event protocol (U1)', () => { const canonical = createCanonicalToolResult('generic', { status: 'complete', data: { value: 'done' } }); const deltas: SubagentDeltaEvent[] = [ - { ...base, type: 'spawned', record: record('subagent-1', 'running'), usage: null }, + { ...base, type: 'spawned', record: summary('subagent-1', 'running'), usage: null }, { ...base, type: 'text_delta', segmentId: 'seg-text', append: 'hel', sequence: 2 }, { ...base, type: 'thinking_delta', segmentId: 'seg-think', append: 'hmm', sequence: 3 }, { @@ -547,7 +606,7 @@ describe('subagent delta event protocol (U1)', () => { toolResult: canonical, finishedAt: new Date(1).toISOString(), sequence: 6, }, { ...base, type: 'usage', usage, sequence: 7 }, - { ...base, type: 'terminal', record: record('subagent-1', 'completed'), state: 'completed', usage, sequence: 8 }, + { ...base, type: 'terminal', record: summary('subagent-1', 'completed'), state: 'completed', usage, sequence: 8 }, ]; it('covers every delta variant in an exhaustive switch and validates each against the wire schema', () => { @@ -599,8 +658,28 @@ describe('subagent delta event protocol (U1)', () => { expect(subagentSnapshotSchema.safeParse({ ...valid, sessionRevision: -1 }).success).toBe(false); }); + it('rejects eager transcript records in snapshot and lifecycle delta payloads', () => { + const eagerRecord = record('subagent-eager', 'completed'); + const snapshot = { + sessionId: session, + sessionRevision: 1, + records: [eagerRecord], + live: [], + }; + const terminal = { + ...base, + type: 'terminal', + record: eagerRecord, + state: 'completed', + usage: null, + }; + + expect(subagentSnapshotSchema.safeParse(snapshot).success).toBe(false); + expect(subagentDeltaEventSchema.safeParse(terminal).success).toBe(false); + }); + it('accepts a spawned-delta envelope carrying a queued record (U7 admission queue)', () => { - const queued = { ...base, type: 'spawned', record: record('subagent-queued', 'queued'), usage: null }; + const queued = { ...base, type: 'spawned', record: summary('subagent-queued', 'queued'), usage: null }; expect(subagentEventSchema.safeParse({ sessionId: session, events: [queued] }).success).toBe(true); }); @@ -611,7 +690,7 @@ describe('subagent delta event protocol (U1)', () => { const snapshot = { sessionId: session, sessionRevision: 1, - records: [record('subagent-queued', 'queued')], + records: [summary('subagent-queued', 'queued')], live: [{ sessionId: session, subagentId: 'subagent-queued', runId: uuid, sequence: 0, state: 'queued', segments: [], toolCalls: [], usage: null, result: null, error: null, @@ -760,9 +839,9 @@ describe('subagent delta batcher (U3)', () => { for (let sequence = 1; sequence <= 4; sequence += 1) { batcher.queue(usageDelta(sequence)); } - batcher.queue({ ...baseFields, sequence: 5, type: 'spawned', record: record('subagent-1', 'running'), usage: null }); + batcher.queue({ ...baseFields, sequence: 5, type: 'spawned', record: summary('subagent-1', 'running'), usage: null }); batcher.queue({ - ...baseFields, sequence: 6, type: 'terminal', record: record('subagent-1', 'completed'), state: 'completed', usage: null, + ...baseFields, sequence: 6, type: 'terminal', record: summary('subagent-1', 'completed'), state: 'completed', usage: null, }); vi.advanceTimersByTime(16); @@ -784,7 +863,7 @@ describe('subagent delta batcher (U3)', () => { ...baseFields, sequence: 1, type: 'spawned', - record: { ...record('subagent-1', 'running'), task: 'x'.repeat(2_000) }, + record: { ...summary('subagent-1', 'running'), task: 'x'.repeat(2_000) }, usage: null, }); batcher.queue({ ...baseFields, sequence: 2, type: 'status_changed', status: 'running' }); @@ -792,7 +871,7 @@ describe('subagent delta batcher (U3)', () => { ...baseFields, sequence: 3, type: 'terminal', - record: record('subagent-1', 'completed'), + record: summary('subagent-1', 'completed'), state: 'completed', usage: null, }); diff --git a/electron/tests/unit/subagent-runtime.test.ts b/electron/tests/unit/subagent-runtime.test.ts index d51f9110..706d36be 100644 --- a/electron/tests/unit/subagent-runtime.test.ts +++ b/electron/tests/unit/subagent-runtime.test.ts @@ -19,7 +19,11 @@ import type { Message } from '../../src/shared/types/message'; import type { StreamEvent } from '../../src/main/llm/orchestrator'; import { sumSubagentUsage } from '../../src/shared/usage'; import { createCanonicalToolResult } from '../../src/shared/types/tool-result'; -import type { SubagentDeltaEvent, SubagentLiveProjection } from '../../src/shared/types/subagent'; +import { + summarizeSubagentRecord, + type SubagentDeltaEvent, + type SubagentLiveProjection, +} from '../../src/shared/types/subagent'; import { subagentRecordFromStorageDict, subagentRecordToStorageDict, @@ -728,7 +732,10 @@ describe('SubagentManager delta emission (U2)', () => { await manager.getRunPromise(record.id); expect(terminal).not.toBeNull(); - expect(terminal!.record).toEqual(manager.toDomainRecord(record, { includeLiveTail: true })); + expect(terminal!.record).toEqual(summarizeSubagentRecord( + manager.toDomainRecord(record, { includeLiveTail: true }), + )); + expect(terminal!.record).not.toHaveProperty('chain'); expect(terminal!.state).toBe('completed'); expect(terminal!.usage).toEqual(record.usage); }); @@ -764,7 +771,7 @@ describe('SubagentManager delta emission (U2)', () => { if (event.type === 'terminal') { expect(renderer.live.has(record.id)).toBe(false); expect(renderer.records.find((item) => item.id === record.id)).toEqual(event.record); - expect(event.record).toEqual(manager.toDomainRecord(record)); + expect(event.record).toEqual(summarizeSubagentRecord(manager.toDomainRecord(record))); continue; } if (event.type === 'text_delta' || event.type === 'thinking_delta' || diff --git a/electron/tests/unit/subagent-snapshot-eviction.test.ts b/electron/tests/unit/subagent-snapshot-eviction.test.ts index df956a55..a734d113 100644 --- a/electron/tests/unit/subagent-snapshot-eviction.test.ts +++ b/electron/tests/unit/subagent-snapshot-eviction.test.ts @@ -39,7 +39,7 @@ import { } from '../../src/main/agents/persist-subagent-chains'; import { setSubagentPersistenceRecoveryScheduler } from '../../src/main/agents/subagent-persistence-recovery'; import { buildWaitTool } from '../../src/main/tools/subagent/wait'; -import { createSubagentSnapshot } from '../../src/main/ipc/subagents'; +import { createSubagentDetail, createSubagentSnapshot } from '../../src/main/ipc/subagents'; import { SessionManager } from '../../src/main/session/manager'; import { loadSession, @@ -293,7 +293,7 @@ describe('recovery flush after terminal eviction (P1 #2)', () => { }); describe('subagent snapshot after terminal eviction (P1 #3)', () => { - it('serves the stored full record — chain messages and usage intact — for an evicted summary', async () => { + it('serves a bounded summary and fetches the stored transcript only as detail', async () => { const sid = makeSession(); const record = await completeSubagent('snapshotted', 'summarize findings', sid); // Capture the durable shape BEFORE the flush evicts the runtime record. @@ -312,13 +312,18 @@ describe('subagent snapshot after terminal eviction (P1 #3)', () => { expect(snap).toBeDefined(); expect(snap!.status).toBe('completed'); - // The snapshot must equal the durable row, not the empty-chain summary. + expect(snap).not.toHaveProperty('chain'); + expect(snap!.usage).toEqual(durableUsage); + + // The selected-detail endpoint returns the durable row, not the evicted + // runtime shell whose chain was intentionally emptied. const stored = loadSession(sid, storageOpts)! .subagentChains.find((row) => row.id === record.id)!; - expect(snap!.chain.messages).toHaveLength(messageCount); - expect(messageDigest(snap!.chain.messages)).toEqual(messageDigest(stored.chain.messages)); - expect(messageDigest(snap!.chain.messages)).toEqual(durableDigest); - expect(sumSubagentUsage(snap!)).toEqual(durableUsage); + const detail = createSubagentDetail(sid, record.id).record!; + expect(detail.chain.messages).toHaveLength(messageCount); + expect(messageDigest(detail.chain.messages)).toEqual(messageDigest(stored.chain.messages)); + expect(messageDigest(detail.chain.messages)).toEqual(durableDigest); + expect(sumSubagentUsage(detail)).toEqual(durableUsage); }); it('keeps runtime precedence for active records while an evicted sibling comes from storage', async () => { @@ -337,10 +342,12 @@ describe('subagent snapshot after terminal eviction (P1 #3)', () => { const snapDone = snapshot.records.find((row) => row.id === done.id)!; expect(snapDone.status).toBe('completed'); - expect(snapDone.chain.messages.length).toBeGreaterThan(0); + expect(snapDone).not.toHaveProperty('chain'); + expect(snapDone.usage).not.toBeNull(); const snapActive = snapshot.records.find((row) => row.id === active.id)!; expect(snapActive.status).toBe('pending'); + expect(snapActive).not.toHaveProperty('chain'); expect(snapshot.live.some((projection) => projection.subagentId === active.id)) .toBe(true); }); diff --git a/electron/tests/unit/subagent-view.test.ts b/electron/tests/unit/subagent-view.test.ts index bfd4e7d6..3a051ca4 100644 --- a/electron/tests/unit/subagent-view.test.ts +++ b/electron/tests/unit/subagent-view.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import type { SubagentRecord } from '../../src/shared/types/subagent'; +import type { SubagentRecord, SubagentSummary } from '../../src/shared/types/subagent'; import { EMPTY_SUBAGENT_USAGE_SUMMARY } from '../../src/shared/usage'; import { formatSubagentUsage, @@ -11,11 +11,31 @@ import { SubagentView, } from '../../src/renderer/components/SubagentView'; -function record(id: string, status: SubagentRecord['status'], start_time: string): SubagentRecord { +function record(id: string, status: SubagentSummary['status'], start_time: string): SubagentSummary { return { id, agent_name: id, agent_type: 'worker', agent_tier: 'bloom', task: `Task ${id}`, + agentRole: 'worker', status, chain_id: `${id}-chain`, start_time, end_time: status === 'running' ? null : start_time, - result: null, error: null, parentChainIndex: null, chain: { messages: [] } as SubagentRecord['chain'], + parentChainIndex: null, usage: null, + }; +} + +function transcript(summary: SubagentSummary): SubagentRecord { + return { + id: summary.id, + agent_name: summary.agent_name, + agent_type: summary.agent_type, + agent_tier: summary.agent_tier, + task: summary.task, + status: summary.status, + chain_id: summary.chain_id, + start_time: summary.start_time, + end_time: summary.end_time, + result: null, + error: null, + parentChainIndex: summary.parentChainIndex, + closed: false, + chain: { messages: [] } as SubagentRecord['chain'], }; } @@ -45,13 +65,14 @@ describe('SubagentView', () => { subagents: { state, subagents: state.subagents, groups: { queued: [], running: state.subagents, ended: [] }, totalUsage: null, usageByParentChain: new Map(), usageSummary: EMPTY_SUBAGENT_USAGE_SUMMARY, refresh: async () => {}, retry: async () => {}, - isRetrying: false, applyFromSession: () => {}, selectedId: 'running', select: () => {}, + isRetrying: false, selectedId: 'running', select: () => {}, getDetail: () => ({ id: 'running', name: 'running', type: 'Explorer', tier: 'bloom', state: 'running', task: prompt, elapsed: '1s', isRunning: true, result: null, error: null, usage: { prompt_tokens: 1_234, cached_tokens: 345, completion_tokens: 5_678, total_tokens: 6_912 }, }), - live: new Map(), getLive: () => null, + transcript: { status: 'ready', record: transcript(selectedRecord) }, + retryTranscript: async () => {}, live: new Map(), getLive: () => null, }, onBackToChat: () => {}, openRequest: { generation: 1, id: 'running' }, @@ -82,8 +103,9 @@ describe('SubagentView', () => { subagents: { state, subagents: state.subagents, groups: { queued: state.subagents, running: [], ended: [] }, totalUsage: null, usageByParentChain: new Map(), usageSummary: EMPTY_SUBAGENT_USAGE_SUMMARY, refresh: async () => {}, retry: async () => {}, - isRetrying: false, applyFromSession: () => {}, selectedId: null, select: () => {}, + isRetrying: false, selectedId: null, select: () => {}, getDetail: () => null, live: new Map(), getLive: () => null, + transcript: { status: 'idle' }, retryTranscript: async () => {}, }, onBackToChat: () => {}, openRequest: { generation: 1, id: null }, @@ -107,8 +129,9 @@ describe('SubagentView', () => { state: { status: 'ready', subagents: records }, subagents: records, groups: { queued: [], running: [], ended: records }, totalUsage: null, usageByParentChain: new Map(), usageSummary: EMPTY_SUBAGENT_USAGE_SUMMARY, refresh: async () => {}, retry: async () => {}, - isRetrying: false, applyFromSession: () => {}, selectedId: records[0].id, select: () => {}, + isRetrying: false, selectedId: records[0].id, select: () => {}, getDetail: () => null, live: new Map(), getLive: () => null, + transcript: { status: 'idle' }, retryTranscript: async () => {}, }, onBackToChat: () => {}, openRequest: { generation: 1, id: null }, @@ -124,8 +147,9 @@ describe('SubagentView', () => { state: { status: 'ready', subagents: [] }, subagents: [], groups: { queued: [], running: [], ended: [] }, totalUsage: null, usageByParentChain: new Map(), usageSummary: EMPTY_SUBAGENT_USAGE_SUMMARY, refresh: async () => {}, retry: async () => {}, - isRetrying: false, applyFromSession: () => {}, selectedId: null, select: () => {}, + isRetrying: false, selectedId: null, select: () => {}, getDetail: () => null, live: new Map(), getLive: () => null, + transcript: { status: 'idle' }, retryTranscript: async () => {}, }, onBackToChat: () => {}, openRequest: { generation: 1, id: 'missing-agent' }, diff --git a/electron/tests/unit/use-session-cache.test.ts b/electron/tests/unit/use-session-cache.test.ts index 1df30cba..637d20fd 100644 --- a/electron/tests/unit/use-session-cache.test.ts +++ b/electron/tests/unit/use-session-cache.test.ts @@ -20,7 +20,14 @@ const createdHandlers: Array<(event: { session: Session; draftGeneration?: number; }) => void> = []; -const updatedHandlers: Array<(event: { session: Session }) => void> = []; +type SessionUpdatePatch = { + sessionId: string; + chain: Session['chains'][number]; + activeChainId: string | null; + updatedAt: string; +}; + +const updatedHandlers: Array<(event: SessionUpdatePatch) => void> = []; const workspaceHandlers: Array<(event: { workspace: { cwd: string | null; source: string; status: string }; }) => void> = []; @@ -91,7 +98,7 @@ function installOrchidApi() { if (idx >= 0) createdHandlers.splice(idx, 1); }; }, - onUpdated: (handler: (event: { session: Session }) => void) => { + onUpdated: (handler: (event: SessionUpdatePatch) => void) => { updatedHandlers.push(handler); return () => { const idx = updatedHandlers.indexOf(handler); @@ -157,6 +164,43 @@ describe('useSession shared cache', () => { expect(loadMock).toHaveBeenCalledTimes(2); }); + it('merges a narrow session update without replacing unrelated session state', async () => { + const retainedSubagents = [{ id: 'subagent-retained' } as never]; + const session = makeSession({ + id: 's1', + name: 'Keep me', + subagentChains: retainedSubagents, + }); + listMock.mockResolvedValue([]); + getWorkspaceMock.mockResolvedValue({ cwd: null, source: 'unbound', status: 'unbound' }); + loadMock.mockResolvedValue(session); + + const { __sessionCacheTest } = await import('../../src/renderer/hooks/useSession'); + __sessionCacheTest.reset(); + __sessionCacheTest.ensureBootstrapped(); + await __sessionCacheTest.load('s1'); + + const updatedAt = new Date(Date.parse(session.updatedAt) + 1_000).toISOString(); + const updatedChain = { + ...session.chains[0], + status: ChainStatus.COMPLETED, + endTime: updatedAt, + }; + updatedHandlers[0]?.({ + sessionId: session.id, + chain: updatedChain, + activeChainId: null, + updatedAt, + }); + + const updated = __sessionCacheTest.getActiveSession(); + expect(updated?.name).toBe('Keep me'); + expect(updated?.chains).toEqual([updatedChain]); + expect(updated?.activeChainId).toBeNull(); + expect(updated?.updatedAt).toBe(updatedAt); + expect(updated?.subagentChains).toBe(retainedSubagents); + }); + it('enterDraft clears active session for all consumers', async () => { const session = makeSession({ id: 's1' }); listMock.mockResolvedValue([ diff --git a/electron/tests/unit/use-subagents-detail.test.ts b/electron/tests/unit/use-subagents-detail.test.ts index 4a2529d6..cbf32b0a 100644 --- a/electron/tests/unit/use-subagents-detail.test.ts +++ b/electron/tests/unit/use-subagents-detail.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Usage } from '../../src/shared/types/message'; -import type { SubagentLiveProjection, SubagentRecord } from '../../src/shared/types/subagent'; +import type { SubagentLiveProjection, SubagentSummary } from '../../src/shared/types/subagent'; import { buildSubagentDetail } from '../../src/renderer/hooks/useSubagents'; const durableUsage: Usage = { @@ -19,23 +19,20 @@ const liveUsage: Usage = { reasoning_tokens: 0, }; -function record(status: SubagentRecord['status']): SubagentRecord { +function record(status: SubagentSummary['status']): SubagentSummary { return { id: 'subagent-1', agent_name: 'Explore codebase', agent_type: 'explorer', agent_tier: 'bloom', + agentRole: 'explorer', task: 'Inspect the project', status, chain_id: 'chain-1', start_time: '2026-01-01T00:00:00.000Z', end_time: status === 'completed' ? '2026-01-01T00:00:05.000Z' : null, - result: null, - error: null, parentChainIndex: null, - chain: { - messages: [{ usage: durableUsage }], - } as SubagentRecord['chain'], + usage: durableUsage, }; } diff --git a/electron/tests/unit/use-subagents-lazy-detail.test.tsx b/electron/tests/unit/use-subagents-lazy-detail.test.tsx new file mode 100644 index 00000000..fb0aee7a --- /dev/null +++ b/electron/tests/unit/use-subagents-lazy-detail.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SubagentRecord, SubagentSummary } from '../../src/shared/types/subagent'; +import { useSubagents } from '../../src/renderer/hooks/useSubagents'; + +const sessionId = '11111111-1111-4111-8111-111111111111'; + +const summary: SubagentSummary = { + id: 'subagent-selected', + agent_name: 'Explorer', + agent_type: 'explorer', + agent_tier: 'bloom', + agentRole: 'explorer', + task: 'Inspect the repository', + status: 'completed', + chain_id: 'chain-selected', + start_time: '2026-01-01T00:00:00.000Z', + end_time: '2026-01-01T00:00:01.000Z', + parentChainIndex: 0, + usage: null, +}; + +const transcript = { + ...summary, + result: 'done', + error: null, + closed: false, + chain: { + id: summary.chain_id, + sessionId, + messages: [], + status: 'completed', + selection: null, + modelLabel: null, + agentName: 'explorer', + agentType: 'subagent', + agentTier: 'bloom', + subagentRecord: null, + startTime: summary.start_time, + endTime: summary.end_time, + }, +} as SubagentRecord; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function transcriptFor(row: SubagentSummary): SubagentRecord { + return { + ...transcript, + id: row.id, + agent_name: row.agent_name, + task: row.task, + chain_id: row.chain_id, + chain: { ...transcript.chain, id: row.chain_id }, + }; +} + +describe('useSubagents lazy transcript hydration', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('loads summaries first and fetches only the selected transcript', async () => { + const detail = vi.fn().mockResolvedValue({ + sessionId, + subagentId: summary.id, + record: transcript, + }); + window.orchid = { + config: { get: vi.fn().mockResolvedValue({ subagents: { hydration_buffer_kb: 256 } }) }, + session: { onSubagentsChanged: () => () => undefined }, + subagents: { + snapshot: vi.fn().mockResolvedValue({ + sessionId, + sessionRevision: 1, + records: [summary], + live: [], + }), + detail, + onEvent: () => () => undefined, + }, + } as never; + + const { result } = renderHook(() => useSubagents(sessionId)); + await waitFor(() => expect(result.current.state.status).toBe('ready')); + expect(detail).not.toHaveBeenCalled(); + expect(result.current.subagents[0]).not.toHaveProperty('chain'); + + act(() => result.current.select(summary.id)); + + await waitFor(() => expect(result.current.transcript.status).toBe('ready')); + expect(detail).toHaveBeenCalledOnce(); + expect(detail).toHaveBeenCalledWith({ sessionId, subagentId: summary.id }); + expect(result.current.transcript).toEqual({ status: 'ready', record: transcript }); + }); + + it('ignores a late transcript response after another row is selected', async () => { + const second = { + ...summary, + id: 'subagent-second', + agent_name: 'Worker', + chain_id: 'chain-second', + task: 'Implement the fix', + }; + const firstRequest = deferred<{ + sessionId: string; + subagentId: string; + record: SubagentRecord; + }>(); + const secondRequest = deferred<{ + sessionId: string; + subagentId: string; + record: SubagentRecord; + }>(); + const detail = vi.fn(({ subagentId }: { subagentId: string }) => ( + subagentId === summary.id ? firstRequest.promise : secondRequest.promise + )); + window.orchid = { + config: { get: vi.fn().mockResolvedValue({ subagents: { hydration_buffer_kb: 256 } }) }, + session: { onSubagentsChanged: () => () => undefined }, + subagents: { + snapshot: vi.fn().mockResolvedValue({ + sessionId, + sessionRevision: 1, + records: [summary, second], + live: [], + }), + detail, + onEvent: () => () => undefined, + }, + } as never; + + const { result } = renderHook(() => useSubagents(sessionId)); + await waitFor(() => expect(result.current.state.status).toBe('ready')); + + act(() => result.current.select(summary.id)); + await waitFor(() => expect(detail).toHaveBeenCalledWith({ sessionId, subagentId: summary.id })); + act(() => result.current.select(second.id)); + await waitFor(() => expect(detail).toHaveBeenCalledWith({ sessionId, subagentId: second.id })); + + await act(async () => { + secondRequest.resolve({ sessionId, subagentId: second.id, record: transcriptFor(second) }); + }); + await waitFor(() => expect(result.current.transcript).toEqual({ + status: 'ready', + record: transcriptFor(second), + })); + + await act(async () => { + firstRequest.resolve({ sessionId, subagentId: summary.id, record: transcript }); + }); + expect(result.current.transcript).toEqual({ + status: 'ready', + record: transcriptFor(second), + }); + }); +}); diff --git a/electron/tests/unit/use-subagents-live.test.ts b/electron/tests/unit/use-subagents-live.test.ts index 17593e36..fe3f35ad 100644 --- a/electron/tests/unit/use-subagents-live.test.ts +++ b/electron/tests/unit/use-subagents-live.test.ts @@ -4,7 +4,7 @@ import type { Usage } from '../../src/shared/types/message'; import type { SubagentDeltaEvent, SubagentLiveProjection, - SubagentRecord, + SubagentSummary, SubagentSpawnedEvent, SubagentTerminalEvent, } from '../../src/shared/types/subagent'; @@ -32,11 +32,12 @@ import { const sessionA = '11111111-1111-4111-8111-111111111111'; const sessionB = '22222222-2222-4222-8222-222222222222'; -function record(id: string, status: SubagentRecord['status'], start = '2026-01-01T00:00:00.000Z'): SubagentRecord { +function record(id: string, status: SubagentSummary['status'], start = '2026-01-01T00:00:00.000Z'): SubagentSummary { return { id, agent_name: id, agent_type: 'subagent', agent_tier: 'bloom', task: id, + agentRole: 'general', status, chain_id: `${id}-chain`, start_time: start, end_time: null, - result: null, error: null, parentChainIndex: null, chain: { messages: [] } as SubagentRecord['chain'], + parentChainIndex: null, usage: null, }; } @@ -64,14 +65,14 @@ function deltaBase(options: DeltaFactoryOptions = {}) { }; } -function spawned(id: string, runId: string, rec: SubagentRecord, sequence = 0, revision = 0): SubagentSpawnedEvent { +function spawned(id: string, runId: string, rec: SubagentSummary, sequence = 0, revision = 0): SubagentSpawnedEvent { return { ...deltaBase({ subagentId: id, runId, sessionRevision: revision }), sequence, type: 'spawned', record: rec, usage: null }; } function terminal( id: string, runId: string, - rec: SubagentRecord, + rec: SubagentSummary, sequence: number, usage: Usage | null = null, revision = 0, @@ -90,7 +91,7 @@ function batch(events: SubagentDeltaEvent[], sessionId = sessionA): SubagentEven return { sessionId, events }; } -function snapshot(sessionId: string, sessionRevision: number, records: SubagentRecord[], live: SubagentLiveProjection[] = []): SubagentSnapshot { +function snapshot(sessionId: string, sessionRevision: number, records: SubagentSummary[], live: SubagentLiveProjection[] = []): SubagentSnapshot { return { sessionId, sessionRevision, records, live: live.map((item) => ({ ...item, sessionId })) }; } @@ -98,7 +99,7 @@ function snapshot(sessionId: string, sessionRevision: number, records: SubagentR function seeded( sessionId: string, revision: number, - records: SubagentRecord[] = [], + records: SubagentSummary[] = [], live: SubagentLiveProjection[] = [], ): SubagentStreamState { return seedSubagentSnapshot( @@ -220,7 +221,7 @@ describe('subagent delta application', () => { expect(state.live.get('one')?.sequence).toBe(100); expect(state.highWater.get('one')).toBe(100); - const done = { ...record('one', 'completed'), end_time: '2026-01-01T00:01:40.000Z', result: 'done' }; + const done = { ...record('one', 'completed'), end_time: '2026-01-01T00:01:40.000Z' }; state = applyDeltaBatch(state, batch([terminal('one', 'run-1', done, 101)])); expect(state.records).not.toBe(seededRecords); expect(state.records[0]).toBe(done); @@ -273,13 +274,12 @@ describe('subagent delta application', () => { }]); }); - it('terminal removes the live entry and replaces the record with the authoritative durable record', () => { + it('terminal removes the live entry and replaces the row with the authoritative summary', () => { const usage: Usage = { prompt_tokens: 7, cached_tokens: 1, completion_tokens: 3, total_tokens: 10, reasoning_tokens: 0 }; - const done: SubagentRecord = { + const done: SubagentSummary = { ...record('one', 'completed'), end_time: '2026-01-01T00:00:05.000Z', - result: 'finished', - chain: { messages: [{ usage }] } as SubagentRecord['chain'], + usage, }; let state = seeded(sessionA, 3, [record('one', 'running')], [projection({ subagentId: 'one', sequence: 3 })]); state = applyDeltaBatch(state, batch([terminal('one', 'run-1', done, 4, usage)])); @@ -288,7 +288,7 @@ describe('subagent delta application', () => { expect(state.records[0]).toBe(done); expect(state.records[0].status).toBe('completed'); const detail = buildSubagentDetail(state.records[0], Date.parse('2026-01-01T00:00:06.000Z'), state.live.get('one') ?? null); - expect(detail.result).toBe('finished'); + expect(detail.result).toBeNull(); expect(detail.usage).toEqual(usage); expect(detail.isRunning).toBe(false); }); @@ -429,11 +429,10 @@ describe('delta/snapshot parity', () => { it('reaches terminal parity: live entry removed, record replaced at the same revision', () => { const usage: Usage = { prompt_tokens: 3, cached_tokens: 0, completion_tokens: 2, total_tokens: 5, reasoning_tokens: 0 }; - const done: SubagentRecord = { + const done: SubagentSummary = { ...record(id, 'completed'), end_time: '2026-01-01T00:00:05.000Z', - result: 'finished', - chain: { messages: [{ usage }] } as SubagentRecord['chain'], + usage, }; const revision = 12; const fromDeltas = applyDeltaBatch( @@ -497,25 +496,20 @@ describe('hydration buffering and reseed floor', () => { expect(state.hydration).toBe('loading'); }); - it('enforces the byte bound for record-carrying deltas (spawned with populated chain)', () => { + it('keeps lifecycle record carriers bounded regardless of transcript size', () => { let state = bindSubagentSession(createSubagentStreamState(), sessionA); - const chainMessages = Array.from({ length: 20 }, (_, i) => ({ - id: `msg-${i}`, role: 'assistant', content: 'x'.repeat(100), type: 'text', - tool_calls: null, tool_call_id: null, name: null, thinking: null, - timestamp: '2026-01-01T00:00:00.000Z', usage: null, hidden: false, tool_result: null, - })); - const heavyRecord: SubagentRecord = { + const summary: SubagentSummary = { ...record('heavy', 'running'), - chain: { messages: chainMessages } as SubagentRecord['chain'], + task: 'x'.repeat(100), }; - const spawnEvent = spawned('heavy', 'run-heavy', heavyRecord, 1, 5); + const spawnEvent = spawned('heavy', 'run-heavy', summary, 1, 5); const eventBytes = estimateDeltaBytes(spawnEvent); - expect(eventBytes).toBeGreaterThan(2048); + expect(eventBytes).toBeLessThan(2048); state = applyDeltaBatch(state, batch([spawnEvent]), { hydrationBufferBytes: 2048 }); - expect(state.buffered).toHaveLength(0); - expect(state.bufferedBytes).toBe(0); - expect(state.reseedFloor).toBe(5); + expect(state.buffered).toEqual([spawnEvent]); + expect(state.bufferedBytes).toBe(eventBytes); + expect(state.reseedFloor).toBeNull(); expect(state.hydration).toBe('loading'); }); @@ -627,10 +621,10 @@ describe('snapshot hydration guards', () => { expect(state.records).toEqual([stale]); expect(isSubagentSnapshotAffine(state, snapshot(sessionA, 2, []), state.generation)).toBe(true); state = applyDeltaBatch(state, batch([textDelta(2, 'more', { sessionRevision: 2 })])); - const fresh = { ...record('one', 'completed'), chain: { messages: [{ role: 'assistant', content: 'durable' }] } as SubagentRecord['chain'] }; + const fresh = { ...record('one', 'completed'), task: 'fresh durable summary' }; state = seedSubagentSnapshot(state, snapshot(sessionA, 2, [fresh], [projection({ subagentId: 'one', sequence: 1 })])); expect(state.generation).toBe(generation + 1); - expect(state.records[0].chain.messages).toEqual([{ role: 'assistant', content: 'durable' }]); + expect(state.records[0].task).toBe('fresh durable summary'); expect(state.live.get('one')?.sequence).toBe(2); }); @@ -690,10 +684,10 @@ describe('subagent usage summary identity (U5 history input)', () => { expect(state.live.get('one')?.usage).toEqual(runUsage); expect(deriveSubagentUsageSummary(state.records, summary)).toBe(summary); - const done: SubagentRecord = { + const done: SubagentSummary = { ...record('one', 'completed'), end_time: '2026-01-01T00:00:05.000Z', - chain: { messages: [{ usage: runUsage }] } as SubagentRecord['chain'], + usage: runUsage, }; state = applyDeltaBatch(state, batch([terminal('one', 'run-1', done, 3, runUsage)])); const updated = deriveSubagentUsageSummary(state.records, summary); @@ -745,7 +739,7 @@ describe('run rotation for resumed subagents', () => { ])); expect(state.live.get('one')?.segments).toEqual([{ kind: 'text', id: 'seg-text', content: 'run A work' }]); - const doneA = { ...record('one', 'completed'), end_time: '2026-01-01T00:00:05.000Z', result: 'A done' }; + const doneA = { ...record('one', 'completed'), end_time: '2026-01-01T00:00:05.000Z' }; state = applyDeltaBatch(state, batch([terminal('one', 'run-A', doneA, 2, null, 3)])); expect(state.live.has('one')).toBe(false); expect(state.records[0].status).toBe('completed'); @@ -765,12 +759,11 @@ describe('run rotation for resumed subagents', () => { expect(state.highWater.get('one')).toBe(1); // Run B terminal replaces the record with run B's authoritative record. - const doneB = { ...record('one', 'completed'), end_time: '2026-01-01T00:02:00.000Z', result: 'B done' }; + const doneB = { ...record('one', 'completed'), end_time: '2026-01-01T00:02:00.000Z' }; state = applyDeltaBatch(state, batch([terminal('one', 'run-B', doneB, 2, null, 6)])); expect(state.live.has('one')).toBe(false); expect(state.records).toHaveLength(1); expect(state.records[0]).toBe(doneB); - expect(state.records[0].result).toBe('B done'); }); it('drops late deltas from the old run after rotation', () => { @@ -780,7 +773,7 @@ describe('run rotation for resumed subagents', () => { textDelta(1, 'A', { runId: 'run-A', sessionRevision: 2 }), ])); state = applyDeltaBatch(state, batch([ - terminal('one', 'run-A', { ...record('one', 'completed'), result: 'A' }, 2, null, 3), + terminal('one', 'run-A', record('one', 'completed'), 2, null, 3), ])); // Rotate to run B. @@ -804,7 +797,7 @@ describe('run rotation for resumed subagents', () => { spawned('one', 'run-A', record('one', 'pending'), 0, 1), ])); state = applyDeltaBatch(state, batch([ - terminal('one', 'run-A', { ...record('one', 'completed'), result: 'done' }, 1, null, 2), + terminal('one', 'run-A', record('one', 'completed'), 1, null, 2), ])); expect(state.records[0].status).toBe('completed'); @@ -815,7 +808,6 @@ describe('run rotation for resumed subagents', () => { expect(state.records).toHaveLength(1); expect(state.records[0]).toBe(resumed); expect(state.records[0].status).toBe('running'); - expect(state.records[0].result).toBeNull(); }); it('leaves the stream and record untouched for a duplicate spawned of the same run', () => { @@ -843,7 +835,7 @@ describe('run rotation for resumed subagents', () => { spawned('one', 'run-A', record('one', 'pending'), 0, 1), ])); state = applyDeltaBatch(state, batch([ - terminal('one', 'run-A', { ...record('one', 'completed'), result: 'done' }, 1, null, 2), + terminal('one', 'run-A', record('one', 'completed'), 1, null, 2), ])); expect(groupSubagents(state.records).ended.map((item) => item.id)).toEqual(['one']); @@ -855,7 +847,7 @@ describe('run rotation for resumed subagents', () => { // Terminal again, then a resume parked in the queue lands in the queued bucket. state = applyDeltaBatch(state, batch([ - terminal('one', 'run-B', { ...record('one', 'completed'), result: 'done again' }, 1, null, 4), + terminal('one', 'run-B', record('one', 'completed'), 1, null, 4), ])); expect(groupSubagents(state.records).ended.map((item) => item.id)).toEqual(['one']); state = applyDeltaBatch(state, batch([spawned('one', 'run-C', record('one', 'queued'), 0, 5)]));