From f6f36c83a76399da2cee35793aa299e30eb4f16d Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca Date: Sat, 11 Jul 2026 15:57:12 +0800 Subject: [PATCH 1/3] refactor(chat): session data architecture and event stream debugging --- src/providers/ChatViewProvider.ts | 218 +++++----- src/providers/chat/SessionHandler.ts | 10 +- src/providers/chat/StreamEventHandler.ts | 15 +- src/providers/chat/SubagentPersistence.ts | 393 ------------------ src/providers/chat/index.ts | 1 - src/services/MessageStreamService.ts | 10 +- src/services/SessionService.ts | 269 ++++++++++-- src/services/SubagentTracker.ts | 123 +++++- src/shared/createPlainObjectSnapshot.ts | 134 ++++++ src/utils/Logger.ts | 95 +++-- tests/integration/subagent-tracker.test.ts | 45 ++ ...hat-send-deduplication-regression.test.mjs | 1 - .../stream-event-orchestration.test.mjs | 31 ++ ...tton-interrupted-badge-regression.test.mjs | 2 +- .../create-plain-object-snapshot.test.ts | 51 +++ tests/services/logger-sanitization.test.mjs | 32 ++ .../services/message-stream-service.test.mjs | 14 +- tests/services/session-crud.test.mjs | 37 +- .../structured-output-streaming.test.mjs | 1 - .../unit/activity-timeline-collapse.test.mjs | 34 +- ...assistant-header-thinking-variant.test.mjs | 14 +- ...assistant-message-collapsed-logic.test.mjs | 34 +- ...render-source-of-truth-regression.test.mjs | 7 +- ...centralized-session-error-ordering.test.ts | 41 ++ tests/webview/session-error-banner.test.mjs | 2 +- ...rite-pending-checklist-regression.test.mjs | 22 + webview/shared/src/chat/ChatShell.tsx | 188 ++++----- webview/shared/src/chat/MessageComponents.tsx | 136 ++++-- .../lib/assistantBlockPresentation.test.ts | 47 +++ .../chat/lib/assistantBlockPresentation.ts | 97 +++++ .../src/chat/lib/conversationProjection.ts | 9 +- webview/shared/src/chat/lib/messageHandler.ts | 44 +- webview/shared/src/chat/lib/store.test.ts | 77 ++++ webview/shared/src/chat/lib/store.ts | 121 ++++-- webview/shared/src/chat/lib/types.ts | 2 + 35 files changed, 1543 insertions(+), 814 deletions(-) delete mode 100644 src/providers/chat/SubagentPersistence.ts create mode 100644 src/shared/createPlainObjectSnapshot.ts create mode 100644 tests/services/create-plain-object-snapshot.test.ts create mode 100644 tests/services/logger-sanitization.test.mjs create mode 100644 tests/webview/centralized-session-error-ordering.test.ts create mode 100644 tests/webview/todowrite-pending-checklist-regression.test.mjs create mode 100644 webview/shared/src/chat/lib/assistantBlockPresentation.test.ts create mode 100644 webview/shared/src/chat/lib/assistantBlockPresentation.ts diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index b69db03..9a81baf 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -142,7 +142,6 @@ import { SessionHandler, StreamEventHandler, StructuredOutputProcessor, - SubagentPersistence, type AssistantHistoryMarker, type ChatModelOption, type ChatSlashCommand, @@ -288,6 +287,12 @@ export class ChatViewProvider private activeStreamSessionId: string | undefined; private pendingStreamWebviewEvents: Array<{ event: unknown; sessionId?: string }> = []; private streamWebviewFlushTimer: ReturnType | undefined; + /** + * Debug-only mirror of every event that reaches this provider. This is sent + * to the webview but deliberately never enters SessionService persistence. + */ + private pendingLiveEventDebugEvents: Array<{ event: unknown; sessionId?: string }> = []; + private liveEventDebugFlushTimer: ReturnType | undefined; private pendingRawSdkPersistenceBySessionId = new Map(); private rawSdkPersistenceFlushTimer: ReturnType | undefined; private currentTodoItems: unknown[] = []; @@ -389,6 +394,35 @@ export class ChatViewProvider }); } + private enqueueLiveEventDebugEvent( + event: unknown, + sessionId: string | undefined, + ): void { + this.pendingLiveEventDebugEvents.push({ event, sessionId }); + if (this.liveEventDebugFlushTimer) { + return; + } + this.liveEventDebugFlushTimer = setTimeout(() => { + this.flushLiveEventDebugEvents(); + }, 32); + } + + private flushLiveEventDebugEvents(): void { + if (this.liveEventDebugFlushTimer) { + clearTimeout(this.liveEventDebugFlushTimer); + this.liveEventDebugFlushTimer = undefined; + } + const events = this.pendingLiveEventDebugEvents; + if (events.length === 0) { + return; + } + this.pendingLiveEventDebugEvents = []; + this.view?.webview.postMessage({ + type: "liveEventStreamDebugBatch", + events, + }); + } + private enqueueRawSdkEventPersistence( sessionId: string, event: unknown, @@ -675,7 +709,6 @@ export class ChatViewProvider private diagnosticsLogger!: DiagnosticsLogger; private structuredOutputProcessor!: StructuredOutputProcessor; private planManager!: PlanManager; - private subagentPersistence!: SubagentPersistence; private compactionManager!: CompactionManager; private historyProcessor!: HistoryProcessor; private modelAndAgentManager!: ModelAndAgentManager; @@ -805,20 +838,7 @@ export class ChatViewProvider this.planManager, ); - // 4. SubagentPersistence - this.subagentPersistence = new SubagentPersistence( - this.context.workspaceState, - this.subagentTracker, - logger, - asRecord, - firstNonEmptyString, - this.normalizeSubagentStatus.bind(this), - this.mergeSubagentEntries.bind(this), - this.hydrateSubagentsFromPayload.bind(this), - this.resolveSubagentPayloadSessionId.bind(this), - ); - - // 5. CompactionManager + // 4. CompactionManager this.compactionManager = new CompactionManager( this.context.workspaceState, this.serverManager, @@ -828,7 +848,7 @@ export class ChatViewProvider this.processHistoryMessages.bind(this), ); - // 6. HistoryProcessor + // 5. HistoryProcessor this.historyProcessor = new HistoryProcessor( this.context.workspaceState, logger, @@ -840,7 +860,7 @@ export class ChatViewProvider this.planManager, ); - // 7. ModelAndAgentManager + // 6. ModelAndAgentManager this.modelAndAgentManager = new ModelAndAgentManager( this.context.globalState, this.serverManager, @@ -850,23 +870,21 @@ export class ChatViewProvider firstNonEmptyString, ); - // 8. QueueManager + // 7. QueueManager this.queueManager = new QueueManager(logger); - // 9. SessionHandler + // 8. SessionHandler this.sessionHandler = new SessionHandler( this.sessionService, this.historyProcessor, - this.subagentPersistence, this.compactionManager, this.modelAndAgentManager, logger, ); - // 10. StreamEventHandler + // 9. StreamEventHandler this.streamEventHandler = new StreamEventHandler( this.structuredOutputProcessor, - this.subagentPersistence, this.compactionManager, this.diagnosticsLogger, this.geminiTokenTracker, @@ -1598,7 +1616,8 @@ export class ChatViewProvider sessionId: sessionId, messages: messages, - rawSdkEventPayloads: sessionHistory.rawSdkEventPayloads, + rawEventStream: { events: sessionHistory.events }, + subagents: sessionHistory.subagents, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); await this.compactionManager.sendCompactionViewStateForMessages( @@ -1713,7 +1732,6 @@ export class ChatViewProvider try { log.featureStep(flow, 'deleting_session'); await this.sessionService.deleteSession(sessionId); - await this.clearPersistedSubagentSnapshot(sessionId); await this.compactionManager.clearPersistedCompactionViewState(sessionId); const currentSession = await this.sessionService.getCurrentSession(); @@ -1935,14 +1953,6 @@ export class ChatViewProvider } } - /** - * Wrapper: Clear persisted subagent snapshot - * Delegates to SubagentPersistence module - */ - private async clearPersistedSubagentSnapshot(sessionId: string): Promise { - return this.subagentPersistence.clearPersistedSubagentSnapshot(sessionId); - } - /** * Wrapper: View plan * Delegates to PlanManager module @@ -2208,20 +2218,6 @@ export class ChatViewProvider return this.historyProcessor.hasAssistantHistoryAdvanced(latest, baseline); } - /** - * Subagent methods - delegate to SubagentPersistence - */ - private async persistSubagentLiveState( - sessionId: string, - payload: unknown, - ): Promise { - await this.subagentPersistence.persistSubagentLiveState(sessionId, payload as SubagentUpdatePayload); - } - - private buildSubagentPayloadFromMessage(message: any, sessionId?: string): any { - return this.subagentPersistence.buildSubagentPayloadFromMessage(message, sessionId ?? ''); - } - /** * Diagnostics logging - delegate to DiagnosticsLogger */ @@ -2925,7 +2921,7 @@ export class ChatViewProvider this.logHistoryRenderDiagnostics( "webview.ready.current-session", currentSession.id, - sessionHistory.rawSdkEventPayloads, + sessionHistory.events, messages, ); this.view?.webview.postMessage({ @@ -2933,7 +2929,8 @@ export class ChatViewProvider sessionId: currentSession.id, messages: messages, - rawSdkEventPayloads: sessionHistory.rawSdkEventPayloads, + rawEventStream: { events: sessionHistory.events }, + subagents: sessionHistory.subagents, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); await this.sendPersistedCompactionViewState(currentSession.id); @@ -2969,7 +2966,11 @@ export class ChatViewProvider } case "sendMessage": case "sendPrompt": { - this.logger.error(`[DEBUG][HOST] Received ${message.type} instead of questionReply!`, { message }); + this.logger.debug("Received prompt dispatch from webview", { + type: message.type, + sessionId: message.sessionId, + clientRequestId: message.clientRequestId, + }); const correlationId = this.logger.startFeatureFlow('send-message', { hasFiles: message.files?.length > 0, hasImages: message.images?.length > 0, @@ -3276,7 +3277,8 @@ export class ChatViewProvider sessionId: createdSession.id, messages: [], rawMessages: [], - rawSdkEventPayloads: [], + rawEventStream: { events: [] }, + subagents: { summariesByParentMessageId: {}, detailsById: {} }, }); // Non-blocking follow-up work: @@ -3286,7 +3288,6 @@ export class ChatViewProvider void (async () => { try { await Promise.all([ - this.clearPersistedSubagentSnapshot(createdSession.id), this.persistSessionSettings(createdSession.id, { agent: "build", }), @@ -3814,7 +3815,7 @@ export class ChatViewProvider this.logHistoryRenderDiagnostics( "retryLastMessage.reload", retrySessionId, - sessionHistory.rawSdkEventPayloads, + sessionHistory.events, messages, ); this.view?.webview.postMessage({ @@ -3822,7 +3823,8 @@ export class ChatViewProvider sessionId: retrySessionId, messages: messages, - rawSdkEventPayloads: sessionHistory.rawSdkEventPayloads, + rawEventStream: { events: sessionHistory.events }, + subagents: sessionHistory.subagents, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); } catch (err) { @@ -4040,20 +4042,58 @@ export class ChatViewProvider // Always run subagent tracking before any session-scoped early return so child // session events are captured regardless of which session is active in the UI. const subagentUpdate = this.subagentTracker.consumeStreamEvent(event); + // Child-session events (including session.error) belong in the parent + // session's card, persisted tape, and debug panel. Preserve the original + // child ID inside the event payload, but use the tracker-resolved parent + // ID as the storage/display bucket. + const subagentParentSessionId = subagentUpdate + ? this.resolveSubagentPayloadSessionId(subagentUpdate) + : undefined; if (subagentUpdate) { this.view?.webview.postMessage({ type: "subagentUpdate", ...subagentUpdate, }); this.sendProcessingSessionsUpdate(); - void this.subagentPersistence.persistSubagentUpdateSnapshot( - subagentUpdate, + const subagentSessionId = + subagentParentSessionId || this.currentSessionId; + if (subagentSessionId) { + void this.sessionService + .persistCentralizedSubagentProjection(subagentSessionId, subagentUpdate) + .catch((persistError) => { + this.logger.warn("Failed to persist centralized subagent projection", { + err: persistError, + }); + }); + } + } + + // Keep an unfiltered, client-only mirror for diagnosing why a live event + // is absent from the persisted event stream. This path stays ahead of all + // persistence/presentation gates and never calls SessionService. + this.enqueueLiveEventDebugEvent( + event, + subagentParentSessionId || + eventSessionId || + this.activeStreamSessionId || this.currentSessionId, - this.sessionService, - (msg) => this.view?.webview.postMessage(msg) - ).catch((persistError) => { - this.logger.warn("Failed to persist subagent stream snapshot", { err: persistError }); - }); + ); + + // Persist the raw event before any presentation/session-processing gate. + // Subagent tracker updates intentionally run ahead of those gates; keeping + // persistence here prevents the UI from showing a live subagent that is + // absent from the centralized event stream after an early return below. + const persistenceSessionId = + subagentParentSessionId || + eventSessionId || + this.activeStreamSessionId || + this.currentSessionId; + if (persistenceSessionId) { + this.enqueueRawSdkEventPersistence( + persistenceSessionId, + { ...event, sessionId: persistenceSessionId }, + isTerminalLifecycleEvent, + ); } // Sync server-generated session title from session.updated events. @@ -4251,7 +4291,11 @@ export class ChatViewProvider this.logger.warn("Failed to log stream event", { err: error }); } - const resolvedSessionId = eventSessionId || this.activeStreamSessionId || this.currentSessionId; + const resolvedSessionId = + subagentParentSessionId || + eventSessionId || + this.activeStreamSessionId || + this.currentSessionId; const info = (properties?.info as Record | undefined) || {}; const preRenderPreview = @@ -4488,6 +4532,7 @@ export class ChatViewProvider this.unsubscribe = undefined; } this.flushStreamWebviewEvents(); + this.flushLiveEventDebugEvents(); this.flushRawSdkEventPersistence(); this.isBootstrappingWebview = false; this.hasInitializedWebview = false; @@ -4608,7 +4653,8 @@ export class ChatViewProvider } private async loadCentralizedRenderableHistory(sessionId: string): Promise<{ - rawSdkEventPayloads: unknown[]; + events: unknown[]; + subagents: import("../services/SessionService").CentralizedSubagentProjection; messages: any[]; }> { this.logger.debug(`[loadCentralizedRenderableHistory] START sessionId=${sessionId}`); @@ -4618,8 +4664,8 @@ export class ChatViewProvider sessionId, ); - const rawArray = Array.isArray(rawSessionPayloads.rawSdkEventPayloads) - ? rawSessionPayloads.rawSdkEventPayloads + const rawArray = Array.isArray(rawSessionPayloads.rawEventStream.events) + ? rawSessionPayloads.rawEventStream.events : []; this.logger.debug(`[loadCentralizedRenderableHistory] Loaded ${rawArray.length} items from disk in ${Date.now() - start}ms`); @@ -4642,12 +4688,17 @@ export class ChatViewProvider }); return { - rawSdkEventPayloads: safeRawSdkEventPayloads, + events: safeRawSdkEventPayloads, + subagents: rawSessionPayloads.subagents, messages, }; } catch (err: any) { this.logger.error(`[loadCentralizedRenderableHistory] ERROR: ${err.message}`, { stack: err.stack }); - return { rawSdkEventPayloads: [], messages: [] }; + return { + events: [], + subagents: { summariesByParentMessageId: {}, detailsById: {} }, + messages: [], + }; } } @@ -5920,28 +5971,6 @@ export class ChatViewProvider return this.compactionManager.sendPersistedCompactionViewState(sessionId); } - /** - * Callback: Sync subagent snapshot for session - * Delegates to SubagentPersistence module - */ - private async syncSubagentSnapshotForSession( - sessionId: string, - messages: any[], - ): Promise { - const snapshot = await this.subagentPersistence.syncSubagentSnapshotForSession( - sessionId, - messages, - ); - const normalized = this.remapOrphanedSubagentKeys(snapshot, messages); - if (normalized !== snapshot) { - await this.subagentPersistence.savePersistedSubagentSnapshot( - sessionId, - normalized, - ); - } - return normalized; - } - /** * Remap entries in summariesByParentMessageId whose key is not a real * message ID (orphan-* synthetic keys produced when a child session is @@ -7887,16 +7916,6 @@ export class ChatViewProvider duration: duration, }, }); - const snapshotFromFinalMessage = this.buildSubagentPayloadFromMessage( - finalMessage, - session.id, - ); - if (snapshotFromFinalMessage) { - await this.persistSubagentLiveState( - session.id, - snapshotFromFinalMessage, - ); - } this.view?.webview.postMessage({ type: "messageResponse", @@ -10084,6 +10103,7 @@ export class ChatViewProvider this.unsubscribe = undefined; } this.flushStreamWebviewEvents(); + this.flushLiveEventDebugEvents(); this.flushRawSdkEventPersistence(); this.quotaService.off("quotaUpdate", this.handleQuotaUpdate); if (this.webviewMessageListener) { diff --git a/src/providers/chat/SessionHandler.ts b/src/providers/chat/SessionHandler.ts index c45db99..5208f68 100644 --- a/src/providers/chat/SessionHandler.ts +++ b/src/providers/chat/SessionHandler.ts @@ -8,7 +8,6 @@ import type { SessionService } from "../../services/SessionService"; import type { HistoryProcessor } from "./HistoryProcessor"; -import type { SubagentPersistence } from "./SubagentPersistence"; import type { CompactionManager } from "./CompactionManager"; import type { ModelAndAgentManager } from "./ModelAndAgentManager"; @@ -20,7 +19,6 @@ export class SessionHandler { constructor( private sessionService: SessionService, private historyProcessor: HistoryProcessor, - private subagentPersistence: SubagentPersistence, private compactionManager: CompactionManager, private modelAndAgentManager: ModelAndAgentManager, private logger: ReturnType, @@ -160,7 +158,8 @@ export class SessionHandler { const centralizedSessionData = await this.sessionService.loadCentralizedSessionData( sessionId, ); - const rawSdkEventPayloads = centralizedSessionData.rawSdkEventPayloads; + const rawEventStream = centralizedSessionData.rawEventStream; + const subagents = centralizedSessionData.subagents; const rawMessages = await this.sessionService.getMessages(sessionId); const fallbackMessages = Array.isArray(rawMessages) ? rawMessages : []; const messages = await this.historyProcessor.processHistoryMessages( @@ -168,14 +167,14 @@ export class SessionHandler { sessionId, ); - await this.subagentPersistence.syncSubagentSnapshotForSession(sessionId, messages); await this.modelAndAgentManager.applySessionSettings(sessionId); this.postMessage({ type: "chatHistory", sessionId, messages, - rawSdkEventPayloads, + rawEventStream, + subagents, }); await this.compactionManager.sendCompactionViewStateForMessages( sessionId, @@ -202,7 +201,6 @@ export class SessionHandler { try { await this.sessionService.deleteSession(sessionId); - await this.subagentPersistence.clearPersistedSubagentSnapshot(sessionId); await this.compactionManager.clearPersistedCompactionViewState(sessionId); const currentSessionId = this.getCurrentSessionId(); diff --git a/src/providers/chat/StreamEventHandler.ts b/src/providers/chat/StreamEventHandler.ts index 0135723..7c1f47d 100644 --- a/src/providers/chat/StreamEventHandler.ts +++ b/src/providers/chat/StreamEventHandler.ts @@ -8,7 +8,6 @@ */ import type { StructuredOutputProcessor } from "./StructuredOutputProcessor"; -import type { SubagentPersistence } from "./SubagentPersistence"; import type { CompactionManager } from "./CompactionManager"; import type { DiagnosticsLogger } from "./DiagnosticsLogger"; import type { GeminiTokenUsageTracker } from "../../services/GeminiTokenUsageTracker"; @@ -28,7 +27,6 @@ export class StreamEventHandler { constructor( private structuredOutputProcessor: StructuredOutputProcessor, - private subagentPersistence: SubagentPersistence, private compactionManager: CompactionManager, private diagnosticsLogger: DiagnosticsLogger, private geminiTokenTracker: GeminiTokenUsageTracker, @@ -79,12 +77,13 @@ export class StreamEventHandler { const subagentUpdate = this.getSubagentUpdate(properties, enrichedEvent); if (subagentUpdate) { this.logSubagentUpdate(subagentUpdate); - await this.subagentPersistence.persistSubagentUpdateSnapshot( - subagentUpdate, - sessionId ?? this.getCurrentSessionId(), - this.sessionService, - this.postMessage, - ); + const targetSessionId = sessionId ?? this.getCurrentSessionId(); + if (targetSessionId) { + await this.sessionService.persistCentralizedSubagentProjection( + targetSessionId, + subagentUpdate, + ); + } } // Track token usage diff --git a/src/providers/chat/SubagentPersistence.ts b/src/providers/chat/SubagentPersistence.ts deleted file mode 100644 index 4168b0b..0000000 --- a/src/providers/chat/SubagentPersistence.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * SubagentPersistence Module - * - * Handles persisting/loading subagent snapshots and building payloads from messages. - * - * Extracted from ChatViewProvider.ts (~250 lines) - */ - -import * as vscode from "vscode"; -import type { SubagentTracker } from "../../services/SubagentTracker"; -import type { SubagentUpdatePayload } from "../../services/SubagentTracker"; - -export class SubagentPersistence { - private static readonly SUBAGENT_SNAPSHOT_PREFIX = - "opencode.session.subagents."; - - constructor( - private workspaceState: vscode.Memento, - private subagentTracker: SubagentTracker, - private logger: ReturnType, - private asRecord: (value: unknown) => Record | undefined, - private firstNonEmptyString: (...values: unknown[]) => string | undefined, - private normalizeSubagentStatus: (status: unknown) => string, - private mergeSubagentEntries: (existing: any[], updates: any[]) => any[], - private hydrateSubagentsFromPayload: ( - parentMessageId: string, - payload: SubagentUpdatePayload, - sessionId: string, - ) => any[], - private resolveSubagentPayloadSessionId: (payload: { - summariesByParentMessageId?: Record; - sessionId?: string; - childSessionId?: string; - }) => string | undefined, - ) { } - - /** - * Get storage key for subagent snapshot - */ - getSubagentSnapshotStorageKey(sessionId: string): string { - return `${SubagentPersistence.SUBAGENT_SNAPSHOT_PREFIX}${sessionId}`; - } - - /** - * Normalize subagent payload - */ - normalizeSubagentPayload( - payload: unknown, - ): SubagentUpdatePayload { - const rec = this.asRecord(payload) || {}; - const summariesByParentMessageId = - this.asRecord(rec.summariesByParentMessageId) || {}; - const detailsById = this.asRecord(rec.detailsById) || {}; - return { - summariesByParentMessageId: - summariesByParentMessageId as SubagentUpdatePayload["summariesByParentMessageId"], - detailsById: detailsById as SubagentUpdatePayload["detailsById"], - }; - } - - /** - * Merge subagent payloads - */ - mergeSubagentPayloads( - existing: SubagentUpdatePayload, - incoming: SubagentUpdatePayload, - ): SubagentUpdatePayload { - const mergedSummaries: Record = {}; - const existingSummaries = - this.asRecord(existing.summariesByParentMessageId) || {}; - const incomingSummaries = - this.asRecord(incoming.summariesByParentMessageId) || {}; - const parentMessageIds = new Set([ - ...Object.keys(existingSummaries), - ...Object.keys(incomingSummaries), - ]); - for (const parentMessageId of Array.from(parentMessageIds)) { - const merged = this.mergeSubagentEntries( - Array.isArray(existingSummaries[parentMessageId]) ? existingSummaries[parentMessageId] as any[] : [], - Array.isArray(incomingSummaries[parentMessageId]) - ? (incomingSummaries[parentMessageId] as Array>) - : [], - ); - if (merged.length > 0) { - mergedSummaries[parentMessageId] = merged; - } - } - - const mergedDetails: Record = {}; - const existingDetails = this.asRecord(existing.detailsById) || {}; - const incomingDetails = this.asRecord(incoming.detailsById) || {}; - const detailIds = new Set([ - ...Object.keys(existingDetails), - ...Object.keys(incomingDetails), - ]); - for (const detailId of Array.from(detailIds)) { - const prev = this.asRecord(existingDetails[detailId]) || {}; - const next = this.asRecord(incomingDetails[detailId]) || {}; - mergedDetails[detailId] = { - ...prev, - ...next, - id: this.firstNonEmptyString(next.id, prev.id, detailId) || detailId, - }; - } - - return { - summariesByParentMessageId: - mergedSummaries as SubagentUpdatePayload["summariesByParentMessageId"], - detailsById: mergedDetails as SubagentUpdatePayload["detailsById"], - }; - } - - /** - * Load persisted subagent snapshot - */ - async loadPersistedSubagentSnapshot( - sessionId: string, - ): Promise { - const raw = this.workspaceState.get( - this.getSubagentSnapshotStorageKey(sessionId), - ); - if (!raw) { - return null; - } - const normalized = this.normalizeSubagentPayload(raw); - const hasEntries = - Object.keys(normalized.summariesByParentMessageId || {}).length > 0 || - Object.keys(normalized.detailsById || {}).length > 0; - return hasEntries ? normalized : null; - } - - /** - * Save persisted subagent snapshot - */ - async savePersistedSubagentSnapshot( - sessionId: string, - payload: SubagentUpdatePayload, - ): Promise { - await this.workspaceState.update( - this.getSubagentSnapshotStorageKey(sessionId), - payload, - ); - } - - /** - * Clear persisted subagent snapshot - */ - async clearPersistedSubagentSnapshot( - sessionId: string, - ): Promise { - await this.workspaceState.update( - this.getSubagentSnapshotStorageKey(sessionId), - undefined, - ); - } - - /** - * Persist subagent live state - */ - async persistSubagentLiveState( - sessionId: string, - payload: SubagentUpdatePayload, - ): Promise { - const existing = await this.loadPersistedSubagentSnapshot(sessionId); - const merged = existing - ? this.mergeSubagentPayloads(existing, payload) - : payload; - await this.savePersistedSubagentSnapshot(sessionId, merged); - return merged; - } - - /** - * Build subagent payload from message - */ - buildSubagentPayloadFromMessage( - messageRaw: unknown, - fallbackSessionId: string, - ): SubagentUpdatePayload | null { - const message = this.asRecord(messageRaw); - if (!message) { - return null; - } - const info = this.asRecord(message.info); - const messageId = this.firstNonEmptyString( - info?.id, - message.id, - message.messageID, - ); - const subagentsRaw = Array.isArray(message.subagents) - ? message.subagents - : []; - if (!messageId || subagentsRaw.length === 0) { - return null; - } - - const summaries: Array> = []; - const detailsById: Record = {}; - - for (const subagentRaw of subagentsRaw) { - const subagent = this.asRecord(subagentRaw); - if (!subagent) { - continue; - } - const id = this.firstNonEmptyString(subagent.id); - if (!id) { - continue; - } - const parentSessionId = this.firstNonEmptyString( - subagent.parentSessionId, - fallbackSessionId, - ); - const parentMessageId = this.firstNonEmptyString( - subagent.parentMessageId, - messageId, - ); - if (!parentSessionId || !parentMessageId) { - continue; - } - - const normalized: Record = { - ...subagent, - id, - parentSessionId, - parentMessageId, - status: this.normalizeSubagentStatus(subagent.status), - latestActivity: - this.firstNonEmptyString( - subagent.latestActivity, - subagent.description, - ) || "Subagent update", - }; - if (!Array.isArray(normalized.references)) { - normalized.references = []; - } - if (!Array.isArray(normalized.progressEvents)) { - normalized.progressEvents = []; - } - if (!Array.isArray(normalized.thinkingEvents)) { - normalized.thinkingEvents = []; - } - if (!Array.isArray(normalized.conversationEvents)) { - normalized.conversationEvents = []; - } - if (!Array.isArray(normalized.timelineEvents)) { - normalized.timelineEvents = []; - } - - // Log what provider/model data is available - console.log('===SUBAGENT_SPAWN=== [PERSIST] Extracting subagent from message', { - id, - hasProviderID: Boolean(normalized.providerID), - hasModelID: Boolean(normalized.modelID), - hasAgentId: Boolean(normalized.agentId), - providerID: normalized.providerID, - modelID: normalized.modelID, - agentId: normalized.agentId, - originalKeys: Object.keys(subagent), - originalSubagent: subagent, - }); - - summaries.push({ - id, - parentSessionId, - parentMessageId, - childSessionId: normalized.childSessionId, - agentId: normalized.agentId, - providerID: normalized.providerID, - modelID: normalized.modelID, - startedAt: normalized.startedAt, - endedAt: normalized.endedAt, - durationMs: normalized.durationMs, - status: normalized.status, - latestActivity: normalized.latestActivity, - references: normalized.references, - }); - detailsById[id] = normalized; - } - - if (summaries.length === 0) { - return null; - } - - return { - summariesByParentMessageId: { - [messageId]: summaries as SubagentUpdatePayload["summariesByParentMessageId"][string], - } as SubagentUpdatePayload["summariesByParentMessageId"], - detailsById: detailsById as SubagentUpdatePayload["detailsById"], - }; - } - - /** - * Persist subagent update snapshot - */ - async persistSubagentUpdateSnapshot( - payload: { - summariesByParentMessageId?: Record; - detailsById?: Record; - }, - currentSessionId: string | undefined, - sessionService: any, - postMessage: (msg: any) => void, - ): Promise { - const summariesMap = this.asRecord(payload.summariesByParentMessageId) || {}; - const parentMessageIds = Object.keys(summariesMap).filter(Boolean); - if (parentMessageIds.length === 0) { - return; - } - - const sessionId = - this.resolveSubagentPayloadSessionId(payload) || currentSessionId; - if (!sessionId) { - return; - } - - const normalizedPayload = this.normalizeSubagentPayload(payload); - await this.persistSubagentLiveState(sessionId, normalizedPayload); - - const cachedMessages = await sessionService.loadSessionMessages( - sessionId, - ); - if (!Array.isArray(cachedMessages) || cachedMessages.length === 0) { - return; - } - - let hasChanges = false; - const nextMessages = cachedMessages.map((rawMessage: any) => { - const message = this.asRecord(rawMessage); - if (!message) { - return rawMessage; - } - - const info = this.asRecord(message.info); - const messageId = this.firstNonEmptyString( - info?.id, - message.id, - message.messageID, - ); - if (!messageId || !parentMessageIds.includes(messageId)) { - return rawMessage; - } - - const incomingSubagents = this.hydrateSubagentsFromPayload( - messageId, - normalizedPayload, - sessionId, - ); - if (incomingSubagents.length === 0) { - return rawMessage; - } - - const mergedSubagents = this.mergeSubagentEntries( - Array.isArray(message.subagents) ? message.subagents as any[] : [], - incomingSubagents, - ); - const nextMessage: Record = { - ...message, - subagents: mergedSubagents, - }; - hasChanges = true; - return nextMessage; - }); - - if (!hasChanges) { - return; - } - - await sessionService.saveSessionMessages(sessionId, nextMessages); - } - - /** - * Sync subagent snapshot for session - */ - async syncSubagentSnapshotForSession( - sessionId: string, - messages: any[], - ): Promise { - this.subagentTracker.resetForSession(sessionId); - this.subagentTracker.seedFromMessages(messages); - const trackerSnapshot = this.subagentTracker.getSnapshotPayload(); - const persistedSnapshot = - await this.loadPersistedSubagentSnapshot(sessionId); - const mergedSnapshot = persistedSnapshot - ? this.mergeSubagentPayloads(persistedSnapshot, trackerSnapshot) - : trackerSnapshot; - // Note: postMessage callback will be set by the shell - // this.view?.webview.postMessage({ - // type: "subagentSnapshot", - // ...mergedSnapshot, - // }); - await this.savePersistedSubagentSnapshot(sessionId, mergedSnapshot); - return mergedSnapshot; - } -} diff --git a/src/providers/chat/index.ts b/src/providers/chat/index.ts index 8c816be..0e9bb48 100644 --- a/src/providers/chat/index.ts +++ b/src/providers/chat/index.ts @@ -8,7 +8,6 @@ export * from "./types"; export { DiagnosticsLogger } from "./DiagnosticsLogger"; export { StructuredOutputProcessor } from "./StructuredOutputProcessor"; export { PlanManager } from "./PlanManager"; -export { SubagentPersistence } from "./SubagentPersistence"; export { CompactionManager } from "./CompactionManager"; export { HistoryProcessor } from "./HistoryProcessor"; export { ModelAndAgentManager } from "./ModelAndAgentManager"; diff --git a/src/services/MessageStreamService.ts b/src/services/MessageStreamService.ts index 18fac86..0a41809 100644 --- a/src/services/MessageStreamService.ts +++ b/src/services/MessageStreamService.ts @@ -64,6 +64,7 @@ import { createLogger } from '../utils/Logger'; import { LoggingCategories } from '../utils/LoggingSchema'; import * as vscode from "vscode"; import { normalizeSdkStreamEvent } from "./opencodeSdkCompat"; +import { createPlainObjectSnapshot } from "../shared/createPlainObjectSnapshot"; /** * Represents a server-sent event from the OpenCode server. @@ -187,14 +188,7 @@ export class MessageStreamService { } private cloneRawEvent(value: T): T { - if (typeof structuredClone === "function") { - try { - return structuredClone(value); - } catch { - return value; - } - } - return value; + return createPlainObjectSnapshot(value); } private isLikelyStreamTransportFailure(error: unknown): boolean { diff --git a/src/services/SessionService.ts b/src/services/SessionService.ts index 06add95..6b3f442 100644 --- a/src/services/SessionService.ts +++ b/src/services/SessionService.ts @@ -60,6 +60,8 @@ import { sanitizeCentralizedDebugPayload, shouldPersistCentralizedSessionEventPayload, } from "../shared/centralizedDebugPayloadFilter"; +import { createPlainObjectSnapshot } from "../shared/createPlainObjectSnapshot"; +import type { SubagentUpdatePayload } from "./SubagentTracker"; const log = createLogger(LoggingCategories.SESSION_SERVICE); const MAX_CACHED_MESSAGES_PER_SESSION = 200; @@ -73,7 +75,24 @@ const MAX_COMPACT_PROGRESS_EVENTS = 200; const MAX_COMPACT_STEPS = 200; const MAX_COMPACT_SUBAGENTS = 64; const MAX_COMPACT_SUBAGENT_EVENTS = 120; -const MAX_COMPACT_INTERACTIVE_EVENTS = 40; +const MAX_COMPACT_INTERACTIVE_EVENTS = 40; + +/** + * Persisted, normalized projection for the subagent UI. The event stream + * remains the authoritative record; this projection is a bounded cache used + * for fast, deterministic session hydration. + */ +export type CentralizedSubagentProjection = Pick< + SubagentUpdatePayload, + "summariesByParentMessageId" | "detailsById" +>; + +export type CentralizedSessionData = { + schemaVersion: 1; + sessionId: string; + rawEventStream: { events: unknown[] }; + subagents: CentralizedSubagentProjection; +}; function isDataUrl(value: string): boolean { return /^data:[^;]+;base64,/i.test(value); @@ -1252,19 +1271,154 @@ export class SessionService { /** In-memory cache of raw SDK event payloads by session for atomic appends */ private rawSdkEventPayloadCache = new Map(); + /** Normalized subagent projection persisted beside the event stream. */ + private centralizedSubagentProjectionCache = new Map< + string, + CentralizedSubagentProjection + >(); + + /** Serializes envelope writes so raw-event and projection updates cannot race. */ + private centralizedSessionWriteChains = new Map>(); + /** Per-session debounce timer for raw SDK event payload persistence */ private rawSdkEventPersistTimers = new Map>(); - private cloneRawSdkEventPayload(value: T): T { - if (typeof structuredClone === "function") { - try { - return structuredClone(value); - } catch { - return value; - } - } - return value; - } + private cloneRawSdkEventPayload(value: T): T { + return createPlainObjectSnapshot(value); + } + + private emptySubagentProjection(): CentralizedSubagentProjection { + return { summariesByParentMessageId: {}, detailsById: {} }; + } + + private normalizeSubagentProjection( + value: unknown, + ): CentralizedSubagentProjection { + const record = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const summaries = + record.summariesByParentMessageId && + typeof record.summariesByParentMessageId === "object" && + !Array.isArray(record.summariesByParentMessageId) + ? record.summariesByParentMessageId + : {}; + const details = + record.detailsById && + typeof record.detailsById === "object" && + !Array.isArray(record.detailsById) + ? record.detailsById + : {}; + return { + summariesByParentMessageId: sanitizeForPersistence( + summaries, + ) as CentralizedSubagentProjection["summariesByParentMessageId"], + detailsById: sanitizeForPersistence( + details, + ) as CentralizedSubagentProjection["detailsById"], + }; + } + + /** Keep a session envelope from retaining another session's subagent state. */ + private scopeSubagentProjectionToSession( + sessionId: string, + projection: CentralizedSubagentProjection, + ): CentralizedSubagentProjection { + const summariesByParentMessageId: CentralizedSubagentProjection["summariesByParentMessageId"] = {}; + for (const [parentMessageId, summaries] of Object.entries( + projection.summariesByParentMessageId || {}, + )) { + const scoped = (Array.isArray(summaries) ? summaries : []).filter( + (summary) => summary?.parentSessionId === sessionId, + ); + if (scoped.length > 0) { + summariesByParentMessageId[parentMessageId] = scoped; + } + } + + const detailsById: CentralizedSubagentProjection["detailsById"] = {}; + for (const [id, detail] of Object.entries(projection.detailsById || {})) { + if (detail?.parentSessionId === sessionId) { + detailsById[id] = detail; + } + } + return { summariesByParentMessageId, detailsById }; + } + + private readCentralizedSessionData(sessionId: string): CentralizedSessionData { + const stored = this.context.workspaceState.get( + `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, + ); + // Legacy storage was a bare event array. Read it without data loss and + // rewrite it in the versioned envelope on the next persistence operation. + if (Array.isArray(stored)) { + return { + schemaVersion: 1, + sessionId, + rawEventStream: { events: stored }, + subagents: this.emptySubagentProjection(), + }; + } + const record = + stored && typeof stored === "object" && !Array.isArray(stored) + ? (stored as Record) + : {}; + const rawEventStream = + record.rawEventStream && + typeof record.rawEventStream === "object" && + !Array.isArray(record.rawEventStream) + ? (record.rawEventStream as Record) + : {}; + return { + schemaVersion: 1, + sessionId, + rawEventStream: { + events: Array.isArray(rawEventStream.events) ? rawEventStream.events : [], + }, + subagents: this.scopeSubagentProjectionToSession( + sessionId, + this.normalizeSubagentProjection(record.subagents), + ), + }; + } + + private async saveCentralizedSessionData( + data: CentralizedSessionData, + ): Promise { + this.rawSdkEventPayloadCache.set(data.sessionId, [...data.rawEventStream.events]); + this.centralizedSubagentProjectionCache.set(data.sessionId, data.subagents); + await this.context.workspaceState.update( + `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${data.sessionId}`, + data, + ); + await this.context.workspaceState.update( + `${SessionService.LEGACY_SUBAGENT_SNAPSHOT_PREFIX}${data.sessionId}`, + undefined, + ); + } + + private async updateCentralizedSessionData( + sessionId: string, + update: (current: CentralizedSessionData) => CentralizedSessionData, + ): Promise { + const previous = this.centralizedSessionWriteChains.get(sessionId) || Promise.resolve(); + const operation = previous + .catch(() => undefined) + .then(async () => { + await this.saveCentralizedSessionData( + update(this.readCentralizedSessionData(sessionId)), + ); + }); + this.centralizedSessionWriteChains.set(sessionId, operation); + try { + await operation; + } finally { + if (this.centralizedSessionWriteChains.get(sessionId) === operation) { + this.centralizedSessionWriteChains.delete(sessionId); + } + } + } private shouldPersistRawSdkEventPayload(event: unknown): boolean { return shouldPersistCentralizedSessionEventPayload(event); @@ -1338,6 +1492,10 @@ export class SessionService { /** Prefix for storing raw SDK event payloads per session (appended with session ID) */ private static readonly RAW_SDK_EVENT_PAYLOADS_PREFIX = "opencode.session.raw-sdk-event-payloads."; + + /** Retired independent snapshot; cleared after centralized writes. */ + private static readonly LEGACY_SUBAGENT_SNAPSHOT_PREFIX = + "opencode.session.subagents."; /** Key for storing current session ID */ private static readonly SESSION_ID_KEY = "opencode.currentSessionId"; @@ -1759,6 +1917,7 @@ export class SessionService { // Memory fix: evict in-memory caches for the deleted session so data // doesn't accumulate indefinitely for sessions that no longer exist. this.rawSdkEventPayloadCache.delete(sessionId); + this.centralizedSubagentProjectionCache.delete(sessionId); this.sessionHistory = this.sessionHistory.filter((s) => s.id !== sessionId); await this.context.workspaceState.update( `${SessionService.MESSAGES_PREFIX}${sessionId}`, @@ -1768,6 +1927,14 @@ export class SessionService { `opencode.session.raw-messages.${sessionId}`, undefined, ); + await this.context.workspaceState.update( + `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, + undefined, + ); + await this.context.workspaceState.update( + `${SessionService.LEGACY_SUBAGENT_SNAPSHOT_PREFIX}${sessionId}`, + undefined, + ); this.persistState(); log.sessionEvent("delete", sessionId, { @@ -2102,16 +2269,15 @@ export class SessionService { const persisted = events.map((event) => this.cloneRawSdkEventPayload(event), ); - this.rawSdkEventPayloadCache.set(sessionId, persisted); const existingTimer = this.rawSdkEventPersistTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); this.rawSdkEventPersistTimers.delete(sessionId); } - await this.context.workspaceState.update( - `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, - persisted, - ); + await this.updateCentralizedSessionData(sessionId, (current) => ({ + ...current, + rawEventStream: { events: persisted }, + })); } /** @@ -2126,15 +2292,14 @@ export class SessionService { }); return [...cached]; } - const value = this.context.workspaceState.get( - `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, - ); - const raw = Array.isArray(value) ? value : []; + const centralized = this.readCentralizedSessionData(sessionId); + const raw = centralized.rawEventStream.events; this.rawSdkEventPayloadCache.set(sessionId, [...raw]); + this.centralizedSubagentProjectionCache.set(sessionId, centralized.subagents); log.debug("[CENTRALIZED-TAPE][SESSION] load_raw_sdk_events_workspace_state", { sessionId, count: raw.length, - hadStoredArray: Array.isArray(value), + hadStoredArray: raw.length > 0, }); return raw; } @@ -2148,15 +2313,45 @@ export class SessionService { * @param sessionId - The ID of the session to load data for * @returns Object containing centralized raw SDK event payloads */ - async loadCentralizedSessionData(sessionId: string): Promise<{ - rawSdkEventPayloads: unknown[]; - }> { + async loadCentralizedSessionData(sessionId: string): Promise { void this.context.workspaceState.update( `opencode.session.raw-messages.${sessionId}`, undefined, ); - const rawSdkEventPayloads = await this.loadSessionRawSdkEventPayloads(sessionId); - return { rawSdkEventPayloads }; + const events = await this.loadSessionRawSdkEventPayloads(sessionId); + const subagents = + this.centralizedSubagentProjectionCache.get(sessionId) || + this.emptySubagentProjection(); + return { + schemaVersion: 1, + sessionId, + rawEventStream: { events }, + subagents, + }; + } + + /** Merge a live subagent update into the centralized session payload. */ + async persistCentralizedSubagentProjection( + sessionId: string, + update: SubagentUpdatePayload, + ): Promise { + const incoming = this.scopeSubagentProjectionToSession( + sessionId, + this.normalizeSubagentProjection(update), + ); + await this.updateCentralizedSessionData(sessionId, (current) => ({ + ...current, + subagents: { + summariesByParentMessageId: { + ...current.subagents.summariesByParentMessageId, + ...incoming.summariesByParentMessageId, + }, + detailsById: { + ...current.subagents.detailsById, + ...incoming.detailsById, + }, + }, + })); } @@ -2282,17 +2477,19 @@ export class SessionService { let events = this.rawSdkEventPayloadCache.get(sessionId); let loadedFromState = false; if (!Array.isArray(events)) { - const value = this.context.workspaceState.get( - `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, + const centralized = this.readCentralizedSessionData(sessionId); + events = [...centralized.rawEventStream.events]; + this.centralizedSubagentProjectionCache.set( + sessionId, + centralized.subagents, ); - events = Array.isArray(value) ? [...value] : []; this.rawSdkEventPayloadCache.set(sessionId, events); loadedFromState = true; log.debug("[CENTRALIZED-TAPE][SESSION] append_loaded_existing_events", { sessionId, loadedFromState, existingCount: events.length, - hadStoredArray: Array.isArray(value), + hadStoredArray: events.length > 0, }); } @@ -2401,14 +2598,17 @@ export class SessionService { flushedEventsLength: events.length }); try { - await this.context.workspaceState.update(storageKey, [...events]); - const stored = this.context.workspaceState.get(storageKey); + await this.updateCentralizedSessionData(sessionId, (current) => ({ + ...current, + rawEventStream: { events: [...events] }, + })); + const stored = this.readCentralizedSessionData(sessionId); log.info("[CENTRALIZED-TAPE][SESSION] flush_complete", { sessionId, storageKey, flushedEventsLength: events.length, - storedEventsLength: Array.isArray(stored) ? stored.length : 0, - storedIsArray: Array.isArray(stored), + storedEventsLength: stored.rawEventStream.events.length, + storedIsArray: false, }); } catch (error) { log.error("[CENTRALIZED-TAPE][SESSION] flush_failed", { @@ -2438,6 +2638,7 @@ export class SessionService { // Memory fix: release in-memory caches — data has been flushed to disk // above and the service is being torn down. this.rawSdkEventPayloadCache.clear(); + this.centralizedSubagentProjectionCache.clear(); } private async mergeMessagesForSessionAliases( diff --git a/src/services/SubagentTracker.ts b/src/services/SubagentTracker.ts index 302fe31..104e96f 100644 --- a/src/services/SubagentTracker.ts +++ b/src/services/SubagentTracker.ts @@ -1,5 +1,23 @@ +import { createPlainObjectSnapshot } from "../shared/createPlainObjectSnapshot"; + type UnknownRecord = Record; +const SUBAGENT_CONSOLE_DEBUG_ENABLED = false; + +function safeConsoleContext(value: T): T { + return createPlainObjectSnapshot(value); +} + +function debugSubagentLog(message: string, payload: unknown): void { + // These logs are only for deep local debugging. Keep them fully disabled in + // normal runs so the extension host never hands rich object payloads to the + // inspector bridge unless a developer explicitly opts in by editing source. + if (!SUBAGENT_CONSOLE_DEBUG_ENABLED) { + return; + } + console.log(message, safeConsoleContext(payload)); +} + export type SubagentStatus = | "pending" | "running" @@ -214,6 +232,68 @@ function extractErrorText(value: unknown): string { return ""; } +/** + * The server can emit a useful `session.error` followed milliseconds later by + * the same failure serialized as a Bun stack trace. The latter is diagnostic + * data, not a user-facing error: it would otherwise replace the actionable + * message on the subagent card. + */ +function isInternalRuntimeStack(value: string): boolean { + return ( + /\/\$bunfs\//i.test(value) || + (/\b(?:Error|Exception)\b/i.test(value) && + /\bat\s+(?:|[\w$.]+(?:\s+\([^)]*\))?)/i.test(value)) + ); +} + +function isGenericErrorMessage(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return ( + !normalized || + normalized === "session error" || + normalized === "unknown error" || + /^[\w.]+error:?$/.test(normalized) + ); +} + +function toUserFacingErrorMessage(value: string): string { + const trimmed = value.trim(); + if (!isInternalRuntimeStack(trimmed)) { + return trimmed; + } + + if (/providermodelnotfounderror/i.test(trimmed)) { + return "The selected provider model was not found."; + } + + return "The subagent failed to run."; +} + +function errorMessageQuality(value: string, wasInternalStack = false): number { + if (!value.trim()) { + return 0; + } + if (wasInternalStack || isInternalRuntimeStack(value)) { + return 1; + } + return isGenericErrorMessage(value) ? 2 : 3; +} + +function selectUserFacingErrorMessage( + existing: string | undefined, + incomingRaw: string, +): string { + const incoming = toUserFacingErrorMessage(incomingRaw); + const existingQuality = errorMessageQuality(existing || ""); + const incomingQuality = errorMessageQuality( + incoming, + isInternalRuntimeStack(incomingRaw), + ); + + // Keep an earlier actionable server message over a later diagnostic stack. + return incomingQuality >= existingQuality ? incoming : existing || incoming; +} + function toTimestamp(value: unknown, fallback = Date.now()): number { const n = asNumber(value); if (typeof n === "number" && Number.isFinite(n)) { @@ -1385,7 +1465,7 @@ export class SubagentTracker { } // Log session creation with all available info - console.log('===SUBAGENT_SPAWN=== [SESSION_CREATED] Full info object', { + debugSubagentLog('===SUBAGENT_SPAWN=== [SESSION_CREATED] Full info object', { parentSessionId, childSessionId, activeSessionId: this.activeSessionId, @@ -1442,7 +1522,7 @@ export class SubagentTracker { const providerID = asString(info.providerID) || selectedModel?.providerID || undefined; const modelID = asString(info.modelID) || selectedModel?.modelID || undefined; - console.log('===SUBAGENT_SPAWN=== [TRACKER] Creating orphan subagent', { + debugSubagentLog('===SUBAGENT_SPAWN=== [TRACKER] Creating orphan subagent', { detailId, parentSessionId, parentMessageId, @@ -1498,7 +1578,7 @@ export class SubagentTracker { const providerID = detail.providerID || asString(info.providerID) || selectedModel?.providerID || undefined; const modelID = detail.modelID || asString(info.modelID) || selectedModel?.modelID || undefined; - console.log('===SUBAGENT_SPAWN=== [TRACKER] Updating existing subagent', { + debugSubagentLog('===SUBAGENT_SPAWN=== [TRACKER] Updating existing subagent', { detailId, childSessionId, existingProviderID: detail.providerID, @@ -1519,7 +1599,7 @@ export class SubagentTracker { detail.providerID = providerID; detail.modelID = modelID; - console.log('===SUBAGENT_SPAWN=== [TRACKER] After update', { + debugSubagentLog('===SUBAGENT_SPAWN=== [TRACKER] After update', { detailId, finalProviderID: detail.providerID, finalModelID: detail.modelID, @@ -1610,7 +1690,7 @@ export class SubagentTracker { }; // Log the full error extraction context - console.log('===SUBAGENT_SPAWN=== [ERROR_EXTRACTION] Error extraction context', { + debugSubagentLog('===SUBAGENT_SPAWN=== [ERROR_EXTRACTION] Error extraction context', { sessionId, detailId, errorContext, @@ -1631,7 +1711,7 @@ export class SubagentTracker { "Session error"; } - console.log('===SUBAGENT_SPAWN=== [ERROR] Session error received', { + debugSubagentLog('===SUBAGENT_SPAWN=== [ERROR] Session error received', { sessionId, detailId, errorText, @@ -1639,10 +1719,14 @@ export class SubagentTracker { detailModelID: detail.modelID, hasError: Boolean(properties.error), errorKeys: properties.error ? Object.keys(properties.error) : [], - rawError: properties.error ? JSON.stringify(properties.error).slice(0, 500) : undefined, + rawError: properties.error ? safeConsoleContext(properties.error) : undefined, }); - // If we still have a generic error, try to construct a more informative one + errorText = selectUserFacingErrorMessage(detail.errorText, errorText); + + // If we still have a generic error, try to construct a more informative one. + // Do not use this fallback for an internal stack trace: it is intentionally + // downgraded above so it cannot overwrite the server's actionable message. if (errorText === "Session error" || errorText.toLowerCase().includes("error")) { const errorName = asString(errorObj?.name); const modelID = detail.modelID || detail.providerID; @@ -1654,22 +1738,25 @@ export class SubagentTracker { } } + const errorChanged = detail.errorText !== errorText; detail.status = "error"; detail.errorText = errorText; detail.latestActivity = errorText; detail.endedAt = detail.endedAt ?? createdAt; this.recomputeDuration(detail); - this.pushTimeline(detail, { - key: this.makeTimelineKey( - "session.error", - undefined, - undefined, + if (errorChanged) { + this.pushTimeline(detail, { + key: this.makeTimelineKey( + "session.error", + undefined, + undefined, + createdAt, + ), + type: "session.error", + label: errorText, createdAt, - ), - type: "session.error", - label: errorText, - createdAt, - }); + }); + } this.upsertDetail(detail); changedParents.add(detail.parentMessageId); diff --git a/src/shared/createPlainObjectSnapshot.ts b/src/shared/createPlainObjectSnapshot.ts new file mode 100644 index 0000000..fe4e254 --- /dev/null +++ b/src/shared/createPlainObjectSnapshot.ts @@ -0,0 +1,134 @@ +const MAX_SNAPSHOT_DEPTH = 12; + +function isPlainObject(value: object): value is Record { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function snapshotUnknown( + value: unknown, + depth: number, + seen: WeakSet, +): unknown { + if ( + value == null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (typeof value === "function" || typeof value === "symbol") { + return undefined; + } + + if (depth >= MAX_SNAPSHOT_DEPTH) { + return "[omitted: max depth reached]"; + } + + if (Array.isArray(value)) { + return value.map((item) => snapshotUnknown(item, depth + 1, seen)); + } + + if (typeof value !== "object") { + return String(value); + } + + if (seen.has(value)) { + return "[omitted: circular reference]"; + } + seen.add(value); + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value instanceof RegExp) { + return value.toString(); + } + + if (value instanceof URL) { + return value.toString(); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + + if (ArrayBuffer.isView(value)) { + return { + __type: value.constructor.name, + byteLength: value.byteLength, + }; + } + + if (value instanceof ArrayBuffer) { + return { + __type: "ArrayBuffer", + byteLength: value.byteLength, + }; + } + + if (value instanceof Map) { + return { + __type: "Map", + entries: Array.from(value.entries(), ([key, entryValue]) => [ + snapshotUnknown(key, depth + 1, seen), + snapshotUnknown(entryValue, depth + 1, seen), + ]), + }; + } + + if (value instanceof Set) { + return { + __type: "Set", + values: Array.from(value.values(), (entryValue) => + snapshotUnknown(entryValue, depth + 1, seen), + ), + }; + } + + if (!isPlainObject(value)) { + const constructorName = value.constructor?.name; + return constructorName && constructorName !== "Object" + ? `[omitted non-plain object: ${constructorName}]` + : "[omitted non-plain object]"; + } + + const snapshot: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const normalized = snapshotUnknown(nestedValue, depth + 1, seen); + if (typeof normalized !== "undefined") { + snapshot[key] = normalized; + } + } + return snapshot; +} + +export function createPlainObjectSnapshot(value: T): T { + let candidate: unknown = value; + + if (typeof structuredClone === "function") { + try { + candidate = structuredClone(value); + } catch { + // Fall through to JSON/manual snapshotting so we never keep the original + // runtime-owned object graph when cloning fails. + } + } + + try { + return JSON.parse(JSON.stringify(candidate)) as T; + } catch { + return snapshotUnknown(candidate, 0, new WeakSet()) as T; + } +} diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts index f5deec8..7646607 100644 --- a/src/utils/Logger.ts +++ b/src/utils/Logger.ts @@ -26,10 +26,11 @@ * @module Logger */ -import * as vscode from "vscode"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { createPlainObjectSnapshot } from "../shared/createPlainObjectSnapshot"; /** * Log level severity enumeration. @@ -182,11 +183,14 @@ class Logger { if (enableFile && !fs.existsSync(logDir)) { try { fs.mkdirSync(logDir, { recursive: true }); - } catch (error) { - console.warn(`Failed to create log directory ${logDir}:`, error); - // Disable file logging if directory creation fails - enableFile = false; - } + } catch (error) { + console.warn( + `Failed to create log directory ${logDir}:`, + this.sanitizeConsoleValue(error), + ); + // Disable file logging if directory creation fails + enableFile = false; + } } const logFilePath = path.join(logDir, "opencode.log"); @@ -239,9 +243,17 @@ class Logger { /** * Formats a log entry as JSON string. */ - private formatEntry(entry: LogEntry): string { - return JSON.stringify(entry); - } + private formatEntry(entry: LogEntry): string { + return JSON.stringify(entry); + } + + private sanitizeConsoleValue(value: T): T { + // DevTools/inspector can choke on live runtime-owned objects (for example + // HTTP/stream wrappers) even when we only intend to log them for debugging. + // Snapshot everything crossing the console boundary into plain data first so + // logging never retains a live object graph. + return createPlainObjectSnapshot(value); + } /** * Outputs a log entry to configured destinations. @@ -432,11 +444,14 @@ class Logger { this.logBuffer = []; await fs.promises.appendFile(this.config.logFilePath, logsToWrite); - } catch (error) { - // If file logging fails, just log to console and clear buffer - console.error("Failed to write logs to file:", error); - this.logBuffer = []; - } finally { + } catch (error) { + // If file logging fails, just log to console and clear buffer + console.error( + "Failed to write logs to file:", + this.sanitizeConsoleValue(error), + ); + this.logBuffer = []; + } finally { this.isFlushing = false; } } @@ -473,32 +488,38 @@ class Logger { } } catch (error) { // File doesn't exist yet or other error - ignore - if ( - error instanceof Error && - !error.message.includes("ENOENT") - ) { - console.error("Log rotation error:", error); - } + if ( + error instanceof Error && + !error.message.includes("ENOENT") + ) { + console.error( + "Log rotation error:", + this.sanitizeConsoleValue(error), + ); + } } } /** * Logs a message at the specified level. */ - private log( - level: string, - category: string, - message: string, - context?: Record, - error?: Error, - ): void { - const entry: LogEntry = { - timestamp: new Date().toISOString(), - level, - category, - message, - context, - }; + private log( + level: string, + category: string, + message: string, + context?: Record, + error?: Error, + ): void { + const sanitizedContext = context + ? this.sanitizeConsoleValue(context) + : undefined; + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + category, + message, + context: sanitizedContext, + }; if (error) { entry.error = { diff --git a/tests/integration/subagent-tracker.test.ts b/tests/integration/subagent-tracker.test.ts index 010a60a..1d2a668 100644 --- a/tests/integration/subagent-tracker.test.ts +++ b/tests/integration/subagent-tracker.test.ts @@ -344,5 +344,50 @@ describe("SubagentTracker", () => { "Unexpected server error. Check server logs for details.", ); }); + + it("keeps an actionable model error when a later ProviderModelNotFound stack arrives", () => { + tracker.consumeStreamEvent( + makeEvent("message.part.updated", { + part: makeSubtaskPart("parent-session-1", "msg-1", "part-1"), + }), + ); + tracker.consumeStreamEvent( + makeEvent("session.created", { + info: { id: "child-1", parentID: "parent-session-1" }, + }), + ); + tracker.consumeStreamEvent( + makeEvent("session.error", { + sessionID: "child-1", + error: { + message: + "Model not found: opencode-go/qwen3.5-plus. Did you mean: qwen3.6-plus?", + }, + }), + ); + + const payload = tracker.consumeStreamEvent( + makeEvent("session.error", { + sessionID: "child-1", + error: { + message: + "ProviderModelNotFoundError: at (/$bunfs/root/chunk.js:1:1)", + }, + }), + ); + + const erroredDetail = Object.values(payload!.detailsById).find( + (detail) => detail.childSessionId === "child-1", + ); + assert.ok(erroredDetail); + assert.equal( + erroredDetail.errorText, + "Model not found: opencode-go/qwen3.5-plus. Did you mean: qwen3.6-plus?", + ); + assert.equal( + erroredDetail.timelineEvents.filter((event) => event.type === "session.error").length, + 1, + ); + }); }); }); diff --git a/tests/providers/chat-send-deduplication-regression.test.mjs b/tests/providers/chat-send-deduplication-regression.test.mjs index dc5bcff..aa2bccc 100644 --- a/tests/providers/chat-send-deduplication-regression.test.mjs +++ b/tests/providers/chat-send-deduplication-regression.test.mjs @@ -14,7 +14,6 @@ const source = readAllSources( joinFromRoot("src", "providers", "chat", "HistoryProcessor.ts"), joinFromRoot("src", "providers", "chat", "StructuredOutputProcessor.ts"), joinFromRoot("src", "providers", "chat", "PlanManager.ts"), - joinFromRoot("src", "providers", "chat", "SubagentPersistence.ts"), joinFromRoot("src", "providers", "chat", "CompactionManager.ts"), joinFromRoot("src", "providers", "chat", "DiagnosticsLogger.ts"), joinFromRoot("src", "providers", "chat", "QueueManager.ts"), diff --git a/tests/providers/stream-event-orchestration.test.mjs b/tests/providers/stream-event-orchestration.test.mjs index 663d74c..cab29ba 100644 --- a/tests/providers/stream-event-orchestration.test.mjs +++ b/tests/providers/stream-event-orchestration.test.mjs @@ -42,6 +42,37 @@ test('subagent tracker consumes stream events before session gating', () => { ); }); +test('centralized event persistence is queued before stream events can return through session gates', () => { + const persistIndex = streamSubscribeBody.indexOf('enqueueRawSdkEventPersistence('); + const todoGateIndex = streamSubscribeBody.indexOf('handleSdkTodoUpdatedEvent('); + const compactionGateIndex = streamSubscribeBody.indexOf('handleSdkCompactionStreamEvent('); + assert.ok(persistIndex >= 0, 'stream callback should enqueue centralized persistence'); + assert.ok(todoGateIndex >= 0, 'stream callback should retain the todo gate'); + assert.ok(compactionGateIndex >= 0, 'stream callback should retain the compaction gate'); + assert.ok( + persistIndex < todoGateIndex && persistIndex < compactionGateIndex, + 'centralized persistence must happen before early-return gates', + ); +}); + +test('child subagent events are bucketed under their parent session for persistence and live debugging', () => { + assert.match( + streamSubscribeBody, + /const subagentParentSessionId = subagentUpdate[\s\S]*?resolveSubagentPayloadSessionId\(subagentUpdate\)/, + 'stream callback should resolve the parent session from a subagent update', + ); + assert.match( + streamSubscribeBody, + /enqueueLiveEventDebugEvent\([\s\S]*?subagentParentSessionId \|\|\s*eventSessionId/s, + 'live debug event capture should prefer the parent session bucket', + ); + assert.match( + streamSubscribeBody, + /const persistenceSessionId =[\s\S]*?subagentParentSessionId \|\|[\s\S]*?eventSessionId/s, + 'centralized raw persistence should prefer the parent session bucket', + ); +}); + test('session id extraction exists and is used for stream scoping', () => { // Session ID extraction has been refactored into the centralized streaming system assert.match( diff --git a/tests/regression/stop-button-interrupted-badge-regression.test.mjs b/tests/regression/stop-button-interrupted-badge-regression.test.mjs index f76879c..c535f5e 100644 --- a/tests/regression/stop-button-interrupted-badge-regression.test.mjs +++ b/tests/regression/stop-button-interrupted-badge-regression.test.mjs @@ -88,7 +88,7 @@ test("conversation projection detaches late abort badges instead of reordering t ); assert.match( chatShellSource, - /kind:\s*"assistant\.abort"[\s\S]*countCanonicalMessagesAtOrBeforeRawIndex[\s\S]*terminalRawIndex/s, + /kind:\s*"assistant\.abort"[\s\S]*getConversationOrderAfterRawIndex\(terminalRawIndex,\s*7\)/s, "projection should emit a detached interruption entry at the terminal raw position", ); assert.match( diff --git a/tests/services/create-plain-object-snapshot.test.ts b/tests/services/create-plain-object-snapshot.test.ts new file mode 100644 index 0000000..0f21579 --- /dev/null +++ b/tests/services/create-plain-object-snapshot.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { createPlainObjectSnapshot } from "../../src/shared/createPlainObjectSnapshot.js"; + +describe("createPlainObjectSnapshot", () => { + it("converts built-ins and unsupported values into plain replay-safe data", () => { + const error = new Error("boom"); + const value = { + createdAt: new Date("2026-07-11T00:00:00.000Z"), + mapping: new Map([["alpha", { ok: true }]]), + values: new Set([1, "two"]), + bytes: new Uint8Array([1, 2, 3]), + nested: { + count: 42n, + skip: () => "ignored", + }, + failure: error, + }; + + const snapshot = createPlainObjectSnapshot(value) as Record; + + assert.equal(snapshot.createdAt, "2026-07-11T00:00:00.000Z"); + assert.deepEqual(snapshot.mapping, { + __type: "Map", + entries: [["alpha", { ok: true }]], + }); + assert.deepEqual(snapshot.values, { + __type: "Set", + values: [1, "two"], + }); + assert.deepEqual(snapshot.bytes, { + __type: "Uint8Array", + byteLength: 3, + }); + assert.equal(snapshot.nested.count, "42"); + assert.equal("skip" in snapshot.nested, false); + assert.equal(snapshot.failure.message, "boom"); + }); + + it("breaks circular references without returning the original object graph", () => { + const root: Record = { id: "root" }; + root.self = root; + + const snapshot = createPlainObjectSnapshot(root) as Record; + + assert.equal(snapshot.id, "root"); + assert.equal(snapshot.self, "[omitted: circular reference]"); + assert.notEqual(snapshot, root); + }); +}); diff --git a/tests/services/logger-sanitization.test.mjs b/tests/services/logger-sanitization.test.mjs new file mode 100644 index 0000000..c356fe4 --- /dev/null +++ b/tests/services/logger-sanitization.test.mjs @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const loggerSource = readSource( + [joinFromRoot("src", "utils", "Logger.ts")], + "Logger.ts", +); + +test("logger sanitizes context and console-bound errors before output", () => { + assert.match( + loggerSource, + /import \{ createPlainObjectSnapshot \} from "\.\.\/shared\/createPlainObjectSnapshot";/, + "Logger should import the shared plain-object snapshot helper", + ); + assert.match( + loggerSource, + /private sanitizeConsoleValue\(value: T\): T \{[\s\S]*?return createPlainObjectSnapshot\(value\);[\s\S]*?\}/, + "Logger should define a reusable sanitizer for console-bound values", + ); + assert.match( + loggerSource, + /const sanitizedContext = context\s*\?\s*this\.sanitizeConsoleValue\(context\)\s*:\s*undefined;[\s\S]*?context:\s*sanitizedContext,/s, + "Logger should sanitize structured context before creating log entries", + ); + assert.match( + loggerSource, + /console\.error\(\s*"Failed to write logs to file:",\s*this\.sanitizeConsoleValue\(error\),\s*\);/s, + "Logger should sanitize raw file-write errors before passing them to console.error", + ); +}); diff --git a/tests/services/message-stream-service.test.mjs b/tests/services/message-stream-service.test.mjs index 822d556..2743e48 100644 --- a/tests/services/message-stream-service.test.mjs +++ b/tests/services/message-stream-service.test.mjs @@ -13,7 +13,6 @@ const chatProviderSource = readAllSources( joinFromRoot('src', 'providers', 'chat', 'DiagnosticsLogger.ts'), joinFromRoot('src', 'providers', 'chat', 'StructuredOutputProcessor.ts'), joinFromRoot('src', 'providers', 'chat', 'PlanManager.ts'), - joinFromRoot('src', 'providers', 'chat', 'SubagentPersistence.ts'), joinFromRoot('src', 'providers', 'chat', 'CompactionManager.ts'), joinFromRoot('src', 'providers', 'chat', 'HistoryProcessor.ts'), joinFromRoot('src', 'providers', 'chat', 'ModelAndAgentManager.ts'), @@ -111,6 +110,19 @@ test('MessageStreamService unwraps SDK sync event wrappers into canonical stream ); }); +test('MessageStreamService snapshots raw SDK events into plain data before notifying callbacks', () => { + assert.match( + messageStreamSource, + /import \{ createPlainObjectSnapshot \} from "\.\.\/shared\/createPlainObjectSnapshot";/, + 'MessageStreamService should import the shared plain-object snapshot helper', + ); + assert.match( + messageStreamSource, + /private cloneRawEvent\(value: T\): T \{\s*return createPlainObjectSnapshot\(value\);\s*\}/, + 'MessageStreamService should not leak the original SDK event object when cloning fails', + ); +}); + test('MessageStreamService retains event filtering helpers and dedupes mirrored events', () => { // Event filtering remains available for explicitly scoped subscriptions, while default centralized streaming is unscoped. assert.match( diff --git a/tests/services/session-crud.test.mjs b/tests/services/session-crud.test.mjs index 6e19479..01eda61 100644 --- a/tests/services/session-crud.test.mjs +++ b/tests/services/session-crud.test.mjs @@ -204,6 +204,28 @@ test('chat provider no longer accepts legacy assistant snapshot persistence from ); }); +test('centralized subagent projections are scoped to their parent session', () => { + const scopeBody = extractFunctionBody( + sessionServiceSource, + 'private scopeSubagentProjectionToSession(', + ); + assert.match( + scopeBody, + /summary\?\.parentSessionId === sessionId/, + 'summary entries from other sessions must be excluded', + ); + assert.match( + scopeBody, + /detail\?\.parentSessionId === sessionId/, + 'detail entries from other sessions must be excluded', + ); + assert.match( + sessionServiceSource, + /this\.scopeSubagentProjectionToSession\(\s*sessionId,\s*this\.normalizeSubagentProjection\(update\)/s, + 'live subagent persistence must scope incoming updates before merging them', + ); +}); + test('session service upgrades duplicate raw sdk event identities to the richer payload', () => { assert.match( sessionServiceSource, @@ -212,11 +234,24 @@ test('session service upgrades duplicate raw sdk event identities to the richer ); assert.match( sessionServiceSource, - /const existingIndex = events\.findIndex\([\s\S]*getCentralizedDebugPayloadIdentity\(existing\) === eventIdentity[\s\S]*rawSdkEventPersistenceRichness\(snapshot\)\s*>=[\s\S]*rawSdkEventPersistenceRichness\(existingEvent\)/s, + /const existingIndex = identityIndex\.get\(eventIdentity\)[\s\S]*rawSdkEventPersistenceRichness\(snapshot\)\s*>=[\s\S]*rawSdkEventPersistenceRichness\(existingEvent\)/s, 'duplicate raw SDK events should keep the richer payload instead of dropping later updates', ); }); +test('session service snapshots raw sdk payloads into plain data before persistence', () => { + assert.match( + sessionServiceSource, + /import \{ createPlainObjectSnapshot \} from "\.\.\/shared\/createPlainObjectSnapshot";/, + 'SessionService should import the shared plain-object snapshot helper', + ); + assert.match( + sessionServiceSource, + /private cloneRawSdkEventPayload\(value: T\): T \{\s*return createPlainObjectSnapshot\(value\);\s*\}/, + 'SessionService should never fall back to returning the original raw SDK event object', + ); +}); + test('history sidebar emits session create/switch/delete events to extension', () => { // Verify webview session controls post expected protocol messages. assert.match(panelSource, /createSession/, 'panel should post createSession message'); diff --git a/tests/services/structured-output-streaming.test.mjs b/tests/services/structured-output-streaming.test.mjs index a40f7f9..fc53b0c 100644 --- a/tests/services/structured-output-streaming.test.mjs +++ b/tests/services/structured-output-streaming.test.mjs @@ -9,7 +9,6 @@ const chatProviderSource = readAllSources( joinFromRoot('src', 'providers', 'chat', 'DiagnosticsLogger.ts'), joinFromRoot('src', 'providers', 'chat', 'StructuredOutputProcessor.ts'), joinFromRoot('src', 'providers', 'chat', 'PlanManager.ts'), - joinFromRoot('src', 'providers', 'chat', 'SubagentPersistence.ts'), joinFromRoot('src', 'providers', 'chat', 'CompactionManager.ts'), joinFromRoot('src', 'providers', 'chat', 'HistoryProcessor.ts'), joinFromRoot('src', 'providers', 'chat', 'ModelAndAgentManager.ts'), diff --git a/tests/unit/activity-timeline-collapse.test.mjs b/tests/unit/activity-timeline-collapse.test.mjs index c45c156..9c40506 100644 --- a/tests/unit/activity-timeline-collapse.test.mjs +++ b/tests/unit/activity-timeline-collapse.test.mjs @@ -40,12 +40,12 @@ test("activity timeline only collapses after the assistant turn is finished", () ); assert.match( messageComponentsSource, - /const canCollapseCompletedAssistantTurn =[\s\S]*!isCurrentCardLiveAssistantTurn[\s\S]*hasStickyTimelineActivity;/, + /const canCollapseCompletedAssistantTurn =[\s\S]*!isCurrentCardLiveAssistantTurn[\s\S]*hasExpandableTimelineActivity;/, "collapse should only be disabled for the currently live assistant turn, not every older card in the session", ); assert.match( messageComponentsSource, - /const canCollapseCompletedAssistantTurn =[\s\S]*!\(assistantTurnPending && isLatestAssistantMessage && isAfterLatestUserMessage\)[\s\S]*hasStickyTimelineActivity;/, + /const canCollapseCompletedAssistantTurn =[\s\S]*!\(assistantTurnPending && isLatestAssistantMessage && isAfterLatestUserMessage\)[\s\S]*hasExpandableTimelineActivity;/, "the newest assistant card should stay expanded while its turn is still pending, even before stream ids fully attach", ); assert.match( @@ -55,6 +55,19 @@ test("activity timeline only collapses after the assistant turn is finished", () ); }); +test("lifecycle-only timeline events do not enable an empty expand control", () => { + assert.match( + messageComponentsSource, + /const isHiddenLifecycleTimelineEvent =[\s\S]*labelLower === "step-start"[\s\S]*labelLower === "step-finish"/, + "step lifecycle markers should be identified as hidden timeline content", + ); + assert.match( + messageComponentsSource, + /const hasExpandableTimelineActivity = timelineDisplayEvents\.some\([\s\S]*!isHiddenLifecycleTimelineEvent\(event\)/, + "only visible timeline content should enable the collapse/expand control", + ); +}); + test("collapsed activity timeline renders the worked-for summary affordance", () => { assert.match( messageComponentsSource, @@ -122,3 +135,20 @@ test("multi-card block collapse controls render correctly in collapsed and expan ); }); +test("a streaming response block stays expanded without collapse controls", () => { + assert.match( + chatShellSource, + /const isBlockExpanded =\s*isLiveBlock \|\| blockExpandedState\.get\(blockGroupKey\) === true;/, + "the active streaming block should override any collapsed state", + ); + assert.match( + messageComponentsSource, + /isLastInBlock && blockSize > 1 && !isBlockStreaming && !isBlockExpanded/, + "the block expand control should stay hidden while streaming", + ); + assert.match( + messageComponentsSource, + /isLastInBlock && blockSize > 1 && !isBlockStreaming && isBlockExpanded/, + "the block collapse control should stay hidden while streaming", + ); +}); diff --git a/tests/webview/assistant-header-thinking-variant.test.mjs b/tests/webview/assistant-header-thinking-variant.test.mjs index ff69000..62a24d6 100644 --- a/tests/webview/assistant-header-thinking-variant.test.mjs +++ b/tests/webview/assistant-header-thinking-variant.test.mjs @@ -13,6 +13,11 @@ const messageHandlerSource = readSource( "messageHandler.ts", ); +const chatShellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); + test("assistant header renders thinking variant beside agent/model metadata", () => { assert.match( messageComponentsSource, @@ -26,8 +31,8 @@ test("assistant header renders thinking variant beside agent/model metadata", () ); assert.match( messageComponentsSource, - /const showAssistantResponseHeader = hasPrimaryResponseBody;/, - "assistant response header should render for every visible AI response block that has a primary response body", + /const showAssistantResponseHeader =\s*isBlockHeaderAnchor && \(hasPrimaryResponseBody \|\| blockSize > 1\);/, + "assistant response header should render once for its designated response-block anchor", ); assert.match( messageComponentsSource, @@ -39,6 +44,11 @@ test("assistant header renders thinking variant beside agent/model metadata", () /modelName !== "assistant" &&[\s\S]*modelName !== assistantHeaderAgentLabel/s, "assistant response header should avoid duplicating the fallback assistant label as both agent and model", ); + assert.match( + chatShellSource, + /const isBlockHeaderAnchor =\s*blockSize <= 1 \|\| \(isBlockExpanded \? isFirstInBlock : isLastInBlock\);/, + "collapsed blocks should place the header on their visible summary card, while expanded blocks place it on the first card", + ); }); test("streaming and normalized messages persist thinking variant metadata", () => { diff --git a/tests/webview/assistant-message-collapsed-logic.test.mjs b/tests/webview/assistant-message-collapsed-logic.test.mjs index b001778..dc5f3ce 100644 --- a/tests/webview/assistant-message-collapsed-logic.test.mjs +++ b/tests/webview/assistant-message-collapsed-logic.test.mjs @@ -12,13 +12,15 @@ import assert from "node:assert"; // Simulated logic from MessageComponents.tsx function getCollapsedRenderingState({ hasPrimaryResponseBody, - isLastInBlock, + isBlockHeaderAnchor, + blockSize = 1, isStreamingActive, hasCopyableResponseContent, }) { - // 1. Every visible AI response block should keep its header so agent/model - // metadata remains visible even on expanded intermediate cards. - const showAssistantResponseHeader = hasPrimaryResponseBody; + // 1. Agent/model metadata belongs to the AI response block, not every + // message within it. The shell chooses one visible anchor per block. + const showAssistantResponseHeader = + isBlockHeaderAnchor && (hasPrimaryResponseBody || blockSize > 1); // 2. The timestamp is moved inside the bubble and copy button removed for messages WITHOUT copyable text const showTimestampInsideBubble = !isStreamingActive && !hasCopyableResponseContent; @@ -37,7 +39,7 @@ describe("Assistant Message Collapsed State UI Logic", () => { it("should hide header and move timestamp inside bubble for intermediate steps without text", () => { const state = getCollapsedRenderingState({ hasPrimaryResponseBody: false, - isLastInBlock: false, // This is an intermediate subagent step + isBlockHeaderAnchor: false, isStreamingActive: false, hasCopyableResponseContent: false, // No text }); @@ -47,47 +49,49 @@ describe("Assistant Message Collapsed State UI Logic", () => { assert.strictEqual(state.showFooterOutsideBubble, false, "Copy button and external footer should be hidden for intermediate steps"); }); - it("should keep header visible for intermediate steps WITH text (e.g. expanded text message followed by tool call)", () => { + it("should not repeat the header on later expanded messages in the same block", () => { const state = getCollapsedRenderingState({ hasPrimaryResponseBody: true, - isLastInBlock: false, // It's an expanded text message that isn't the final card + isBlockHeaderAnchor: false, isStreamingActive: false, hasCopyableResponseContent: true, // It has text }); - assert.strictEqual(state.showAssistantResponseHeader, true, "Header should remain visible for any response block that has body text"); + assert.strictEqual(state.showAssistantResponseHeader, false, "Header should not repeat on a non-anchor message"); assert.strictEqual(state.showTimestampInsideBubble, false, "Timestamp should NOT be inside the bubble because it has text"); assert.strictEqual(state.showFooterOutsideBubble, true, "Copy button and external footer should be visible because it has text"); }); - it("should show header and external footer for the absolute last message in a block with text", () => { + it("should show header on the collapsed block's visible summary card", () => { const state = getCollapsedRenderingState({ hasPrimaryResponseBody: true, - isLastInBlock: true, // This is the final response + isBlockHeaderAnchor: true, + blockSize: 3, isStreamingActive: false, hasCopyableResponseContent: true, }); - assert.strictEqual(state.showAssistantResponseHeader, true, "Header should be visible for the final message"); + assert.strictEqual(state.showAssistantResponseHeader, true, "Header should be visible on the response-block anchor"); assert.strictEqual(state.showTimestampInsideBubble, false, "Timestamp should NOT be inside the bubble for the final message"); assert.strictEqual(state.showFooterOutsideBubble, true, "Copy button and external footer should be visible for the final message"); }); - it("should hide header only when there is no primary response body", () => { + it("should keep the header on an expanded block's first activity-only card", () => { const state = getCollapsedRenderingState({ hasPrimaryResponseBody: false, - isLastInBlock: true, + isBlockHeaderAnchor: true, + blockSize: 2, isStreamingActive: false, hasCopyableResponseContent: false, }); - assert.strictEqual(state.showAssistantResponseHeader, false, "Header should stay hidden for non-response activity-only cards"); + assert.strictEqual(state.showAssistantResponseHeader, true, "The response-block anchor should retain its header"); }); it("should hide footers while streaming is active", () => { const state = getCollapsedRenderingState({ hasPrimaryResponseBody: true, - isLastInBlock: true, + isBlockHeaderAnchor: true, isStreamingActive: true, hasCopyableResponseContent: true, }); diff --git a/tests/webview/centralized-render-source-of-truth-regression.test.mjs b/tests/webview/centralized-render-source-of-truth-regression.test.mjs index dd41869..4b6e5a7 100644 --- a/tests/webview/centralized-render-source-of-truth-regression.test.mjs +++ b/tests/webview/centralized-render-source-of-truth-regression.test.mjs @@ -133,9 +133,14 @@ test("centralized transcript projection emits session-error entries in tape orde ); assert.match( chatShellSource, - /hasPrimarySessionErrorEvent[\s\S]*?candidateError\.source !== "message\.updated"/s, + /primarySessionErrorEvents[\s\S]*?candidateError\.source !== "message\.updated"/s, "projection should detect when a primary session.error/error event exists", ); + assert.match( + chatShellSource, + /fallbackSpecificSessionErrorEvents[\s\S]*?candidateError\.source === "message\.updated"[\s\S]*?!isGenericSessionErrorMessage\(candidateError\.message\)/s, + "projection should keep specific message.updated errors when primary session.error rows are only generic fallbacks", + ); assert.match( chatShellSource, /hasSpecificSessionErrorEvent[\s\S]*?isGenericSessionErrorMessage\(candidateError\.message\)/s, diff --git a/tests/webview/centralized-session-error-ordering.test.ts b/tests/webview/centralized-session-error-ordering.test.ts new file mode 100644 index 0000000..2513451 --- /dev/null +++ b/tests/webview/centralized-session-error-ordering.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { countCanonicalMessagesAtOrBeforeRawIndex } from "../../webview/shared/src/chat/lib/conversationProjection.js"; + +describe("centralized session-error ordering helpers", () => { + it("ignores hidden canonical messages when anchoring non-message transcript rows", () => { + const count = countCanonicalMessagesAtOrBeforeRawIndex( + [ + { + message: {} as never, + index: 0, + ids: ["msg-user-1"], + rawOrder: 0, + renderKind: "user", + }, + { + message: {} as never, + index: 1, + ids: ["msg-assistant-hidden"], + rawOrder: 3, + renderKind: "hidden", + }, + { + message: {} as never, + index: 2, + ids: ["msg-user-2"], + rawOrder: 7, + renderKind: "user", + }, + ], + 3, + ); + + assert.equal( + count, + 1, + "hidden assistant placeholders must not push session.error rows below later visible user messages", + ); + }); +}); diff --git a/tests/webview/session-error-banner.test.mjs b/tests/webview/session-error-banner.test.mjs index 1a81b2b..4e2ae48 100644 --- a/tests/webview/session-error-banner.test.mjs +++ b/tests/webview/session-error-banner.test.mjs @@ -34,7 +34,7 @@ describe('Session error banner rendering', () => { ); assert.match( chatShellSource, - /kind:\s*"session\.error"[\s\S]*?error:\s*errorEvent[\s\S]*?order:\s*priorMessageCount\s*\*\s*10\s*\+\s*6/s, + /kind:\s*"session\.error"[\s\S]*?error:\s*errorEvent[\s\S]*?order:\s*getConversationOrderAfterRawIndex\(errorEvent\.rawIndex,\s*6\)/s, 'ChatShell must emit a session.error conversation entry ordered from the centralized tape', ); assert.match( diff --git a/tests/webview/todowrite-pending-checklist-regression.test.mjs b/tests/webview/todowrite-pending-checklist-regression.test.mjs new file mode 100644 index 0000000..039c4bd --- /dev/null +++ b/tests/webview/todowrite-pending-checklist-regression.test.mjs @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const messageComponentsSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + +test("pending TodoWrite activities do not render a nested loading component", () => { + const todoWriteStep = messageComponentsSource.match( + /function TodoWriteStep\([\s\S]*?\n\}/, + )?.[0]; + + assert.ok(todoWriteStep, "TodoWrite activity renderer should exist"); + assert.doesNotMatch( + todoWriteStep, + /Generating checklist|AIStatusTicker/, + "the TodoWrite activity row is its own pending indicator and must not add a nested loading state", + ); +}); diff --git a/webview/shared/src/chat/ChatShell.tsx b/webview/shared/src/chat/ChatShell.tsx index af4e177..82f3f9d 100644 --- a/webview/shared/src/chat/ChatShell.tsx +++ b/webview/shared/src/chat/ChatShell.tsx @@ -35,6 +35,7 @@ import { buildMessageConversationEntries, countCanonicalMessagesAtOrBeforeRawIndex, } from "./lib/conversationProjection"; +import { buildAssistantBlockPresentation } from "./lib/assistantBlockPresentation"; import vscode from "./lib/vscode"; import logger from "./lib/logger"; import { config } from "../config"; @@ -1577,6 +1578,32 @@ function buildCentralizedTranscriptProjection( : undefined; }; + const getConversationOrderAfterRawIndex = ( + rawIndex: number, + offset: number, + ): number => { + // IMPORTANT: + // Non-message transcript rows (session.error, session.diff, detached abort) + // must anchor against the *visible* canonical transcript, not against raw + // centralized message count or hidden assistant placeholders. + // + // Example failure mode this prevents: + // - user message + // - hidden assistant placeholder with no renderable bubble yet + // - session.error + // - next user message + // + // If we count the hidden assistant row, or if we multiply by the next slot + // directly, the session.error card is pushed below the later user message. + // We instead anchor to the last visible message at-or-before this raw tape + // index, then place the non-message row inside that message's 10-point slot. + const visibleMessageCount = countCanonicalMessagesAtOrBeforeRawIndex( + renderMessageEntries, + rawIndex, + ); + return (visibleMessageCount - 1) * 10 + offset; + }; + for (const entry of renderMessageEntries) { const terminalRawIndex = getTerminalRawIndex(entry.message); if ( @@ -1763,6 +1790,9 @@ function buildCentralizedTranscriptProjection( if (entry.kind !== "message") { return; } + if (entry.renderKind === "hidden") { + return; + } if ( entry.renderKind === "background-task-reminder" || entry.renderKind === "hidden" @@ -1797,11 +1827,7 @@ function buildCentralizedTranscriptProjection( kind: "assistant.abort", key: `assistant.abort:${entry.ids[0] ?? entry.index}`, messageId: entry.ids[0], - order: - countCanonicalMessagesAtOrBeforeRawIndex( - renderMessageEntries, - terminalRawIndex, - ) * 10 + 7, + order: getConversationOrderAfterRawIndex(terminalRawIndex, 7), }); } @@ -1820,27 +1846,35 @@ function buildCentralizedTranscriptProjection( continue; } seenSessionDiffFingerprints.add(diffFingerprint); - const priorMessageCount = countCanonicalMessagesAtOrBeforeRawIndex( - renderMessageEntries, - rawIndex, - ); conversationEntries.push({ kind: "session.diff", key: `session.diff:${diff.id ?? rawIndex}`, diff, - order: priorMessageCount * 10 + 5, + order: getConversationOrderAfterRawIndex(rawIndex, 5), }); } const parsedSessionErrorEvents = normalizedRawSdkEventPayloads .map((payload, rawIndex) => parseCentralizedSessionErrorEvent(payload, rawIndex)) .filter((event): event is CentralizedSessionErrorEvent => !!event); - const hasPrimarySessionErrorEvent = parsedSessionErrorEvents.some( + const primarySessionErrorEvents = parsedSessionErrorEvents.filter( (candidateError) => candidateError.source !== "message.updated", ); - const sessionErrorEvents = hasPrimarySessionErrorEvent - ? parsedSessionErrorEvents.filter((candidateError) => candidateError.source !== "message.updated") - : parsedSessionErrorEvents; + const primarySpecificSessionErrorEvents = primarySessionErrorEvents.filter( + (candidateError) => !isGenericSessionErrorMessage(candidateError.message), + ); + const fallbackSpecificSessionErrorEvents = parsedSessionErrorEvents.filter( + (candidateError) => + candidateError.source === "message.updated" && + !isGenericSessionErrorMessage(candidateError.message), + ); + const sessionErrorEvents = primarySpecificSessionErrorEvents.length > 0 + ? primarySpecificSessionErrorEvents + : fallbackSpecificSessionErrorEvents.length > 0 + ? fallbackSpecificSessionErrorEvents + : primarySessionErrorEvents.length > 0 + ? primarySessionErrorEvents + : parsedSessionErrorEvents; const hasSpecificSessionErrorEvent = sessionErrorEvents.some( (candidateError) => !isGenericSessionErrorMessage(candidateError.message), ); @@ -1868,15 +1902,11 @@ function buildCentralizedTranscriptProjection( continue; } seenSessionErrorFingerprints.add(fingerprint); - const priorMessageCount = countCanonicalMessagesAtOrBeforeRawIndex( - renderMessageEntries, - errorEvent.rawIndex, - ); conversationEntries.push({ kind: "session.error", key: `session.error:${errorEvent.id ?? errorEvent.rawIndex}`, error: errorEvent, - order: priorMessageCount * 10 + 6, + order: getConversationOrderAfterRawIndex(errorEvent.rawIndex, 6), }); } @@ -2324,94 +2354,31 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ const { entryBlockKeys, + isFirstInBlockByIndex, isAbsoluteLastInBlockByIndex, isLastTextInBlockByIndex, blockSizeByKey, blockHasInlineAbortByKey, - } = useMemo(() => { - const entryBlockKeys: string[] = []; - let currentBlockKey = "initial"; - const assistantBlockEntries: Array<{ index: number; key: string }> = []; - for (let i = 0; i < visibleConversationEntries.length; i++) { - const entry = visibleConversationEntries[i]; - if (entry.kind === "message") { - const role = entry.message.role ?? entry.message.info?.role; - if (role === "user") { - currentBlockKey = - firstNonEmptyString(entry.message.info?.id, entry.message.id) ?? - `user:${i}`; - } else if (role === "assistant") { - assistantBlockEntries.push({ index: i, key: currentBlockKey }); - } - } - entryBlockKeys.push(currentBlockKey); - } - - const isAbsoluteLastInBlockByIndex = new Map(); - const lastTextIndexByKey = new Map(); - for (let pos = 0; pos < assistantBlockEntries.length; pos++) { - const { index, key } = assistantBlockEntries[pos]; - const messageEntry = visibleConversationEntries[index]; - if (messageEntry.kind !== "message") { - continue; - } - const message = messageEntry.message; - const hasText = Boolean( - message.content || message.text || message.info?.content || message.info?.text, - ); - if (hasText) { - lastTextIndexByKey.set(key, index); - } - } - const isLastTextInBlockByIndex = new Map(); - - for (let pos = 0; pos < assistantBlockEntries.length; pos++) { - const { index, key } = assistantBlockEntries[pos]; - const prevKey = pos > 0 ? assistantBlockEntries[pos - 1].key : null; - const nextKey = - pos < assistantBlockEntries.length - 1 - ? assistantBlockEntries[pos + 1].key - : null; - - const isAbsoluteLast = nextKey !== key; - - const isMultiCardBlock = prevKey === key || nextKey === key; - isAbsoluteLastInBlockByIndex.set(index, isMultiCardBlock ? isAbsoluteLast : false); - - const lastTextIndex = lastTextIndexByKey.get(key); - let isLastText = false; - if (isMultiCardBlock) { - if (lastTextIndex !== undefined) { - isLastText = index === lastTextIndex; - } else { - isLastText = isAbsoluteLast; + } = useMemo( + () => buildAssistantBlockPresentation( + visibleConversationEntries.map((entry, index) => { + if (entry.kind !== "message") { + return {}; } - } - isLastTextInBlockByIndex.set(index, isLastText); - } - - const blockSizeByKey = new Map(); - const blockHasInlineAbortByKey = new Map(); - for (const { key, index } of assistantBlockEntries) { - blockSizeByKey.set(key, (blockSizeByKey.get(key) ?? 0) + 1); - const entry = visibleConversationEntries[index]; - if ( - entry.kind === "message" && - entry.message.aborted === true && - entry.message.interruptedPresentation === "inline" - ) { - blockHasInlineAbortByKey.set(key, true); - } - } - - return { - entryBlockKeys, - isAbsoluteLastInBlockByIndex, - isLastTextInBlockByIndex, - blockSizeByKey, - blockHasInlineAbortByKey, - }; - }, [visibleConversationEntries]); + const message = entry.message; + return { + role: message.role ?? message.info?.role, + userBlockKey: firstNonEmptyString(message.info?.id, message.id) ?? `user:${index}`, + hasResponseText: Boolean( + message.content || message.text || message.info?.content || message.info?.text, + ), + hasInlineAbort: + message.aborted === true && message.interruptedPresentation === "inline", + }; + }), + ), + [visibleConversationEntries], + ); const { entryPrefixHeights, messageCountPrefix } = useMemo(() => { const prefixHeights = new Array(visibleConversationEntries.length + 1).fill(0); @@ -2445,8 +2412,11 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ const isLiveBlock = hasLiveAssistantTurn && blockGroupKey === entryBlockKeys[entryBlockKeys.length - 1]; + // A response block must remain fully expanded while it is streaming. + // Once the stream ends, the default collapsed state takes over and + // the completed-turn affordances can appear. const isBlockExpanded = - blockExpandedState.get(blockGroupKey) ?? (isLiveBlock ? true : false); + isLiveBlock || blockExpandedState.get(blockGroupKey) === true; const isLastInBlock = isBlockExpanded ? isAbsoluteLastInBlock : isLastTextInBlock; isHiddenByBlock = blockSize > 1 && !isLastInBlock && !isBlockExpanded; } @@ -2558,13 +2528,21 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ const blockGroupKey = entryBlockKeys[entryIndex]; const isAbsoluteLastInBlock = isAbsoluteLastInBlockByIndex.get(entryIndex) ?? false; + const isFirstInBlock = isFirstInBlockByIndex.get(entryIndex) ?? true; const isLastTextInBlock = isLastTextInBlockByIndex.get(entryIndex) ?? false; const blockSize = blockSizeByKey.get(blockGroupKey) ?? 1; const isLiveBlock = hasLiveAssistantTurn && blockGroupKey === entryBlockKeys[entryBlockKeys.length - 1]; + // Do not allow a persisted collapsed state to hide active stream + // content. Completed blocks return to the default collapsed view. const isBlockExpanded = - blockExpandedState.get(blockGroupKey) ?? (isLiveBlock ? true : false); + isLiveBlock || blockExpandedState.get(blockGroupKey) === true; const isLastInBlock = isBlockExpanded ? isAbsoluteLastInBlock : isLastTextInBlock; + // The header belongs to the response block, not every individual + // assistant message. When collapsed, pin it to the visible summary + // card; when expanded, pin it to the first card in the block. + const isBlockHeaderAnchor = + blockSize <= 1 || (isBlockExpanded ? isFirstInBlock : isLastInBlock); const isHiddenByBlock = blockSize > 1 && !isLastInBlock && !isBlockExpanded; entryHiddenByBlock = isHiddenByBlock; @@ -2589,6 +2567,8 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ blockGroupKey={blockGroupKey} isLastInBlock={isLastInBlock} isBlockExpanded={isBlockExpanded} + isBlockStreaming={isLiveBlock} + isBlockHeaderAnchor={isBlockHeaderAnchor} blockSize={blockSize} isHiddenByBlock={isHiddenByBlock} blockHasInlineAbort={blockHasInlineAbortByKey.get(blockGroupKey)} diff --git a/webview/shared/src/chat/MessageComponents.tsx b/webview/shared/src/chat/MessageComponents.tsx index fcf7610..c3edb1c 100644 --- a/webview/shared/src/chat/MessageComponents.tsx +++ b/webview/shared/src/chat/MessageComponents.tsx @@ -4285,15 +4285,9 @@ function TodoWriteStep({ event }: { event: DisplayEvent }) { // ignore } + // A TodoWrite activity already has its own pending state in the timeline. + // Do not add a second generic loading row before its checklist payload arrives. if (todos.length === 0) { - if (event.status === "pending") { - return ( -
- - Generating checklist... -
- ); - } return null; } @@ -7302,6 +7296,8 @@ function ResponseMessageInner({ blockGroupKey, isLastInBlock, isBlockExpanded, + isBlockStreaming = false, + isBlockHeaderAnchor = true, onSetBlockExpanded, blockSize = 1, isHiddenByBlock = false, @@ -7326,6 +7322,12 @@ function ResponseMessageInner({ blockGroupKey?: string; isLastInBlock?: boolean; isBlockExpanded?: boolean; + // The current assistant block is actively streaming. Its content remains + // expanded and no completed-turn expand/collapse controls are available. + isBlockStreaming?: boolean; + // Exactly one visible card in a response block owns the agent/model/thinking + // metadata and statistics header. + isBlockHeaderAnchor?: boolean; onSetBlockExpanded?: (expanded: boolean) => void; // Total number of assistant cards in this block (1 = single-card, unchanged behaviour). blockSize?: number; @@ -7353,6 +7355,29 @@ function ResponseMessageInner({ // NEW: Use custom hooks for subagent data access const messageId = message?.id || message?.info?.id; const formattedSubagents = useSubagentsForParentMessage(messageId); + // A child session can be observed before OpenCode supplies the final parent + // assistant message id. Those entries are intentionally retained under an + // `orphan-…` key in centralized state. Surface them once on the final visible + // assistant card of the response block instead of dropping them. + const orphanSubagentsForBlock = useMemo(() => { + if (!isLastInBlock || !currentSessionId) { + return [] as SubagentSummary[]; + } + const byId = new Map(); + for (const [parentKey, summaries] of Object.entries( + subagentsByParentMessageId || {}, + )) { + if (!parentKey.startsWith("orphan-")) { + continue; + } + for (const summary of Array.isArray(summaries) ? summaries : []) { + if (summary?.parentSessionId === currentSessionId) { + byId.set(summary.id, summary); + } + } + } + return Array.from(byId.values()); + }, [currentSessionId, isLastInBlock, subagentsByParentMessageId]); const [showSubagents, setShowSubagents] = useState(true); const [showAllSubagents, setShowAllSubagents] = useState(false); @@ -8081,12 +8106,30 @@ const centralizedRawResponse = message?.rawResponse; ? stickyTimelineDisplayEventsRef.current.events : visibleDisplayEvents; + // Step lifecycle markers are retained for timeline bookkeeping, but their + // compact UI is intentionally hidden. They must not leave an empty + // collapsed summary that can be expanded into no visible content. + const isHiddenLifecycleTimelineEvent = (event: DisplayEvent) => { + const labelLower = (event.label || "").trim().toLowerCase(); + const summaryLower = (event.summary || "").trim().toLowerCase(); + return event.internal === true && ( + labelLower === "step-start" || + labelLower === "step-finish" || + (labelLower === "step" && (summaryLower === "start" || summaryLower === "finish")) || + (labelLower === "start" && summaryLower === "start") || + (labelLower === "finish" && summaryLower === "finish") + ); + }; + const hasStickyTimelineActivity = timelineDisplayEvents.length > 0; + const hasExpandableTimelineActivity = timelineDisplayEvents.some( + (event) => !isHiddenLifecycleTimelineEvent(event), + ); const canCollapseCompletedAssistantTurn = cardMessage?.aborted !== true && !isCurrentCardLiveAssistantTurn && !(assistantTurnPending && isLatestAssistantMessage && isAfterLatestUserMessage) && - hasStickyTimelineActivity; + hasExpandableTimelineActivity; const effectiveExpanded = typeof isBlockExpanded === "boolean" ? isBlockExpanded : viewState.showExpandedActivityTimeline; const isAssistantTurnCollapsed = @@ -8472,7 +8515,8 @@ const centralizedRawResponse = message?.rawResponse; const subagents = useMemo(() => { // Filter for active session and current message only const activeSessionId = currentSessionId; - const filtered = formattedSubagents.filter((subagent) => { + const candidates = [...formattedSubagents, ...orphanSubagentsForBlock]; + const filtered = candidates.filter((subagent) => { // Check if in active session if (activeSessionId && subagent.parentSessionId !== activeSessionId) { return false; @@ -8484,11 +8528,12 @@ const centralizedRawResponse = message?.rawResponse; // More flexible message ID matching to handle different ID formats return subagent.parentMessageId === messageId || subagent.id === messageId || - (subagent.parentMessageId && messageId.includes(subagent.parentMessageId)); + (subagent.parentMessageId && messageId.includes(subagent.parentMessageId)) || + (isLastInBlock && subagent.parentMessageId?.startsWith("orphan-")); }); - return filtered; - }, [messageId, currentSessionId, formattedSubagents]); + return Array.from(new Map(filtered.map((subagent) => [subagent.id, subagent])).values()); + }, [messageId, currentSessionId, formattedSubagents, orphanSubagentsForBlock, isLastInBlock]); const previousSubagentCount = useRef(subagents.length); useEffect(() => { @@ -8990,11 +9035,11 @@ const responseBodyChunks = useMemo(() => { }), [cardMessage, planPrelude, shouldShowPlanCard, visibleResponseBodyChunks], ); - // Product contract: every visible AI response block with a primary response - // body must render the top assistant header. Do not reintroduce `isLastInBlock` - // here: expanded intermediate cards need the same header affordance, and the - // screenshot regression came from hiding it for non-last cards. - const showAssistantResponseHeader = hasPrimaryResponseBody; + // The agent/model/thinking and metrics header describes the AI response + // block, rather than an individual assistant message. The shell selects the + // single visible anchor for each collapsed or expanded block. + const showAssistantResponseHeader = + isBlockHeaderAnchor && (hasPrimaryResponseBody || blockSize > 1); const isAborted = cardMessage?.aborted === true; const effectiveInterruptedPresentation = cardMessage?.interruptedPresentation || @@ -9039,21 +9084,9 @@ const responseBodyChunks = useMemo(() => { // Drive the collapsed state from the shared block-level prop when available // (so all non-last cards in the block toggle together), otherwise fall back // to the local viewState for standalone or legacy usage. - const visibleStepsCount = timelineDisplayEvents.filter((event) => { - const labelLower = (event.label || "").trim().toLowerCase(); - const isLifecycleMarkerEvent = - event.internal === true && ( - labelLower === "step-start" || - labelLower === "step-finish" || - (labelLower === "step" && ( - (event.summary || "").trim().toLowerCase() === "start" || - (event.summary || "").trim().toLowerCase() === "finish" - )) || - (labelLower === "start" && (event.summary || "").trim().toLowerCase() === "start") || - (labelLower === "finish" && (event.summary || "").trim().toLowerCase() === "finish") - ); - return !isLifecycleMarkerEvent; - }).length; + const visibleStepsCount = timelineDisplayEvents.filter( + (event) => !isHiddenLifecycleTimelineEvent(event), + ).length; const collapsedTimelineLabel = typeof duration === "number" && Number.isFinite(duration) && duration > 0 ? `Worked for ${formatDuration(duration * 1000)}` @@ -9934,7 +9967,7 @@ const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { {/* Block-level pill for the last card in a multi-card block. When collapsed: replaces ALL the per-card pills with one unified summary and is displayed ABOVE the final text to maintain chronological sense. */} - {isLastInBlock && blockSize > 1 && !isBlockExpanded && ( + {isLastInBlock && blockSize > 1 && !isBlockStreaming && !isBlockExpanded && (
-
{JSON.stringify({ rawEventStream: { sessionId: centralizedSessionId, rawSdkEventPayloads } }, null, 2)}
+
{JSON.stringify(centralizedData, null, 2)}
); diff --git a/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts b/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts new file mode 100644 index 0000000..e915ce5 --- /dev/null +++ b/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { buildAssistantBlockPresentation } from "./assistantBlockPresentation"; + +describe("buildAssistantBlockPresentation", () => { + it("selects the first expanded card and the last text-bearing collapsed card", () => { + const result = buildAssistantBlockPresentation([ + { role: "user", userBlockKey: "user-1" }, + { role: "assistant", hasResponseText: true }, + { role: "assistant", hasResponseText: false }, + { role: "assistant", hasResponseText: true }, + ]); + + assert.deepStrictEqual(result.entryBlockKeys, ["user-1", "user-1", "user-1", "user-1"]); + assert.equal(result.isFirstInBlockByIndex.get(1), true); + assert.equal(result.isFirstInBlockByIndex.get(2), false); + assert.equal(result.isAbsoluteLastInBlockByIndex.get(3), true); + assert.equal(result.isLastTextInBlockByIndex.get(3), true); + assert.equal(result.isLastTextInBlockByIndex.get(1), false); + assert.equal(result.blockSizeByKey.get("user-1"), 3); + }); + + it("keeps the physical last card visible when an activity-only block has no text", () => { + const result = buildAssistantBlockPresentation([ + { role: "user", userBlockKey: "user-1" }, + { role: "assistant", hasResponseText: false }, + { role: "assistant", hasResponseText: false, hasInlineAbort: true }, + ]); + + assert.equal(result.isLastTextInBlockByIndex.get(1), false); + assert.equal(result.isLastTextInBlockByIndex.get(2), true); + assert.equal(result.blockHasInlineAbortByKey.get("user-1"), true); + }); + + it("does not create collapse anchors for a single-card response", () => { + const result = buildAssistantBlockPresentation([ + { role: "user", userBlockKey: "user-1" }, + { role: "assistant", hasResponseText: true }, + ]); + + assert.equal(result.isFirstInBlockByIndex.get(1), true); + assert.equal(result.isAbsoluteLastInBlockByIndex.get(1), false); + assert.equal(result.isLastTextInBlockByIndex.get(1), false); + assert.equal(result.blockSizeByKey.get("user-1"), 1); + }); +}); diff --git a/webview/shared/src/chat/lib/assistantBlockPresentation.ts b/webview/shared/src/chat/lib/assistantBlockPresentation.ts new file mode 100644 index 0000000..e2b485a --- /dev/null +++ b/webview/shared/src/chat/lib/assistantBlockPresentation.ts @@ -0,0 +1,97 @@ +/** + * Presentation metadata for the contiguous assistant messages that belong to + * one user turn. Keeping this separate from ChatShell prevents the collapsed, + * expanded, and streaming render paths from independently deciding which card + * is the top, visible, or final card in a response block. + */ +export interface AssistantBlockPresentationEntry { + role?: string; + /** Stable user message id, supplied only for user entries. */ + userBlockKey?: string; + /** True when the assistant entry has user-visible response text. */ + hasResponseText?: boolean; + /** True when an aborted assistant entry must be represented inline. */ + hasInlineAbort?: boolean; +} + +export interface AssistantBlockPresentation { + /** The response-block key in effect for every transcript entry. */ + entryBlockKeys: string[]; + /** The first assistant card in each response block. */ + isFirstInBlockByIndex: Map; + /** The physical final assistant card, used while the block is expanded. */ + isAbsoluteLastInBlockByIndex: Map; + /** The last text-bearing assistant card, used while the block is collapsed. */ + isLastTextInBlockByIndex: Map; + blockSizeByKey: Map; + blockHasInlineAbortByKey: Map; +} + +/** + * Build all response-block facts in one pass over normalized transcript + * entries. A collapsed block must retain its last text-bearing card, while an + * expanded or live block must retain every card and put block metadata on the + * first one. Those are presentation rules, not message-data mutations. + */ +export function buildAssistantBlockPresentation( + entries: AssistantBlockPresentationEntry[], +): AssistantBlockPresentation { + const entryBlockKeys: string[] = []; + const assistantEntries: Array<{ index: number; key: string }> = []; + let currentBlockKey = "initial"; + + entries.forEach((entry, index) => { + const role = entry.role?.toLowerCase(); + if (role === "user") { + currentBlockKey = entry.userBlockKey || `user:${index}`; + } else if (role === "assistant") { + assistantEntries.push({ index, key: currentBlockKey }); + } + entryBlockKeys.push(currentBlockKey); + }); + + const lastTextIndexByKey = new Map(); + for (const { index, key } of assistantEntries) { + if (entries[index]?.hasResponseText) { + lastTextIndexByKey.set(key, index); + } + } + + const isFirstInBlockByIndex = new Map(); + const isAbsoluteLastInBlockByIndex = new Map(); + const isLastTextInBlockByIndex = new Map(); + const blockSizeByKey = new Map(); + const blockHasInlineAbortByKey = new Map(); + + assistantEntries.forEach(({ index, key }, position) => { + const previousKey = position > 0 ? assistantEntries[position - 1].key : undefined; + const nextKey = position < assistantEntries.length - 1 + ? assistantEntries[position + 1].key + : undefined; + const isMultiCardBlock = previousKey === key || nextKey === key; + const isAbsoluteLast = nextKey !== key; + + isFirstInBlockByIndex.set(index, previousKey !== key); + isAbsoluteLastInBlockByIndex.set(index, isMultiCardBlock && isAbsoluteLast); + const lastTextIndex = lastTextIndexByKey.get(key); + isLastTextInBlockByIndex.set( + index, + isMultiCardBlock && ( + lastTextIndex === undefined ? isAbsoluteLast : lastTextIndex === index + ), + ); + blockSizeByKey.set(key, (blockSizeByKey.get(key) ?? 0) + 1); + if (entries[index]?.hasInlineAbort) { + blockHasInlineAbortByKey.set(key, true); + } + }); + + return { + entryBlockKeys, + isFirstInBlockByIndex, + isAbsoluteLastInBlockByIndex, + isLastTextInBlockByIndex, + blockSizeByKey, + blockHasInlineAbortByKey, + }; +} diff --git a/webview/shared/src/chat/lib/conversationProjection.ts b/webview/shared/src/chat/lib/conversationProjection.ts index 480e47a..64a0b40 100644 --- a/webview/shared/src/chat/lib/conversationProjection.ts +++ b/webview/shared/src/chat/lib/conversationProjection.ts @@ -109,10 +109,17 @@ export function buildMessageConversationEntries( * Count how many canonical message entries existed on or before a raw tape * index. Diff cards use this to place themselves relative to the one canonical * message order instead of owning a separate ordering system. + * + * Hidden assistant placeholders are intentionally excluded here. They are useful + * for transcript reconstruction, but they do not consume visible vertical space + * in the rendered conversation. Counting them would push `session.error` and + * similar inline rows below later visible user messages. */ export function countCanonicalMessagesAtOrBeforeRawIndex( renderMessageEntries: ProjectedRenderMessageEntry[], rawIndex: number, ): number { - return renderMessageEntries.filter((entry) => entry.rawOrder <= rawIndex).length; + return renderMessageEntries.filter( + (entry) => entry.renderKind !== "hidden" && entry.rawOrder <= rawIndex, + ).length; } diff --git a/webview/shared/src/chat/lib/messageHandler.ts b/webview/shared/src/chat/lib/messageHandler.ts index d7c8288..1ef9d1c 100644 --- a/webview/shared/src/chat/lib/messageHandler.ts +++ b/webview/shared/src/chat/lib/messageHandler.ts @@ -11431,16 +11431,24 @@ export function createMessageHandler(dispatch: Dispatch, getState: () case "chatHistory": { let canonicalMessages: Message[] = []; let chatHistorySessionId = ""; + let persistedSubagents: UnknownRecord | undefined; let hydrationPresentationPolicy: SubagentPresentationPolicy = { mode: "hydration", sessionProcessing: false, }; try { terminalErrorReached = false; + dispatch({ type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" }); const centralizedMessages = asArray(data.messages, isMessage); - const rawSdkEventPayloads = Array.isArray((data as UnknownRecord).rawSdkEventPayloads) - ? [...((data as UnknownRecord).rawSdkEventPayloads as unknown[])] - : []; + const rawEventStream = asRecord((data as UnknownRecord).rawEventStream); + // Accept the legacy transport field while existing webviews/session + // payloads roll forward. New payloads use rawEventStream.events. + const rawSdkEventPayloads = Array.isArray(rawEventStream?.events) + ? [...(rawEventStream.events as unknown[])] + : Array.isArray((data as UnknownRecord).rawSdkEventPayloads) + ? [...((data as UnknownRecord).rawSdkEventPayloads as unknown[])] + : []; + persistedSubagents = asRecord((data as UnknownRecord).subagents); const historySessionId = asString(data.sessionId) || null; if (historySessionId) { logger.setSession(historySessionId); @@ -11581,7 +11589,15 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const rawSdkEventPayloads = postHydrationState.rawSdkEventPayloadsBySessionId?.[chatHistorySessionId] ?? []; const extractedHydratedSubagents = - extractSubagentsFromCentralizedEvents(rawSdkEventPayloads); + persistedSubagents && + (asRecord(persistedSubagents.summariesByParentMessageId) || + asRecord(persistedSubagents.detailsById)) + ? { + summariesByParentMessageId: + persistedSubagents.summariesByParentMessageId || {}, + detailsById: persistedSubagents.detailsById || {}, + } + : extractSubagentsFromCentralizedEvents(rawSdkEventPayloads); const normalizedHydratedSubagents = normalizeHydratedSubagentMaps( extractedHydratedSubagents.summariesByParentMessageId, @@ -12232,6 +12248,26 @@ export function createMessageHandler(dispatch: Dispatch, getState: () ); break; } + case "liveEventStreamDebugBatch": { + const events = Array.isArray(data.events) ? data.events : []; + for (const item of events) { + const event = asRecord(item.event) ?? item; + const sessionId = + asString(item.sessionId) || + asString(item.sessionID) || + asString(event.sessionId) || + asString(event.sessionID) || + asString(asRecord(event.properties)?.sessionId) || + asString(asRecord(event.properties)?.sessionID) || + getState().currentSessionId || + null; + dispatch({ + type: "APPEND_LIVE_EVENT_STREAM_DEBUG", + payload: { sessionId, event }, + }); + } + break; + } case "streamEvent": { const stateBeforeStreamEvent = getState(); const payload = asRecord(data.event) ?? data; diff --git a/webview/shared/src/chat/lib/store.test.ts b/webview/shared/src/chat/lib/store.test.ts index 61d92aa..8e0469c 100644 --- a/webview/shared/src/chat/lib/store.test.ts +++ b/webview/shared/src/chat/lib/store.test.ts @@ -483,6 +483,38 @@ describe('streaming reasoning reducer state', () => { }); describe('assistant turn pending lifecycle', () => { + it('starts centralized streaming when a legacy state snapshot omits messages', () => { + const seededState = { + ...initialState, + messages: undefined, + } as unknown as typeof initialState; + + assert.doesNotThrow(() => appReducer(seededState, { + type: 'SET_PROCESSING', + payload: true, + })); + }); + + it('tolerates a legacy streaming snapshot without content during centralized bootstrap', () => { + const seededState = { + ...initialState, + streaming: { + messageId: 'msg-old-assistant', + reasoning: '', + reasoningEvents: [], + steps: [], + progressEvents: [], + edits: [], + isActive: false, + } as unknown as NonNullable, + }; + + assert.doesNotThrow(() => appReducer(seededState, { + type: 'SET_ASSISTANT_TURN_PENDING', + payload: { pending: true, messageId: 'msg-new-assistant' }, + })); + }); + it('clears a stale inactive streaming snapshot when a new assistant turn starts', () => { const seededState = { ...initialState, @@ -709,6 +741,31 @@ describe('raw event capture', () => { }); }); +describe('live event stream debug capture', () => { + it('keeps unfiltered events in browser-only state and clears them on hydration', () => { + const liveOnlyEvent = { + type: 'message.part.updated', + sessionId: 'ses_123', + properties: { part: { type: 'reasoning', delta: 'private live chunk' } }, + }; + const captured = appReducer( + { ...initialState, currentSessionId: 'ses_123' }, + { + type: 'APPEND_LIVE_EVENT_STREAM_DEBUG', + payload: { sessionId: 'ses_123', event: liveOnlyEvent }, + }, + ); + + assert.deepStrictEqual( + captured.liveEventStreamBySessionId?.['ses_123'], + [liveOnlyEvent], + ); + + const cleared = appReducer(captured, { type: 'CLEAR_LIVE_EVENT_STREAM_DEBUG' }); + assert.deepStrictEqual(cleared.liveEventStreamBySessionId, {}); + }); +}); + describe('hasSystemMessagePatternInText', () => { it('should detect square-bracketed system messages in plain text', () => { const text = '[analyze-mode]'; @@ -999,6 +1056,26 @@ describe("appReducer render-stability guards", () => { assert.deepStrictEqual(nextState.streaming?.interactiveEvents, []); }); + it("normalizes incomplete streaming snapshots before subsequent updates", () => { + const seededState = appReducer(initialState, { + type: "SET_STREAMING", + payload: { + messageId: "msg-partial", + isActive: true, + } as any, + }); + + const nextState = appReducer(seededState, { + type: "ADD_STREAMING_STEP", + payload: { id: "step-1", title: "Working", status: "pending" }, + }); + + assert.strictEqual(nextState.streaming?.content, ""); + assert.strictEqual(nextState.streaming?.reasoning, ""); + assert.strictEqual(nextState.streaming?.steps?.length, 1); + assert.deepStrictEqual(nextState.streaming?.reasoningEvents, []); + }); + it("preserves rawSdkEventPayloads order when canonicalizing duplicate assistant turns", () => { const messages: Message[] = [ { diff --git a/webview/shared/src/chat/lib/store.ts b/webview/shared/src/chat/lib/store.ts index fc8319a..376d0f6 100644 --- a/webview/shared/src/chat/lib/store.ts +++ b/webview/shared/src/chat/lib/store.ts @@ -74,6 +74,7 @@ export const initialState: AppState = { messages: [], messagesBySessionId: {}, rawSdkEventPayloadsBySessionId: {}, + liveEventStreamBySessionId: {}, liveToastNotificationsBySessionId: {}, promptQueue: [], queueBySessionId: {}, @@ -195,6 +196,8 @@ export type AppAction = payload: { sessionId: string; events: unknown[] }; } | { type: "APPEND_RAW_SDK_EVENT_PAYLOAD"; payload: { sessionId?: string | null; event: unknown } } + | { type: "APPEND_LIVE_EVENT_STREAM_DEBUG"; payload: { sessionId?: string | null; event: unknown } } + | { type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" } | { type: "APPEND_LIVE_TOAST_NOTIFICATION"; payload: { sessionId?: string | null; notification: CentralizedToastNotification }; @@ -810,70 +813,84 @@ function mergeStreamingSnapshotLocal( existing: StreamingState | null | undefined, incoming: StreamingState, ): StreamingState { + // Stream transport payloads are incremental and may omit fields that are + // present in a fully-hydrated StreamingState. Normalize them before any + // merge/update path reads collection lengths or string methods. + const normalizedIncoming: StreamingState = { + ...incoming, + content: asStringLocal(incoming.content), + reasoning: asStringLocal(incoming.reasoning), + reasoningEvents: Array.isArray(incoming.reasoningEvents) ? incoming.reasoningEvents : [], + steps: Array.isArray(incoming.steps) ? incoming.steps : [], + progressEvents: Array.isArray(incoming.progressEvents) ? incoming.progressEvents : [], + edits: Array.isArray(incoming.edits) ? incoming.edits : [], + interactiveEvents: Array.isArray(incoming.interactiveEvents) ? incoming.interactiveEvents : [], + rawSdkEventPayloads: Array.isArray(incoming.rawSdkEventPayloads) + ? incoming.rawSdkEventPayloads + : [], + }; const shouldResetTimeline = !!existing?.messageId && - !!incoming.messageId && - existing.messageId !== incoming.messageId; + !!normalizedIncoming.messageId && + existing.messageId !== normalizedIncoming.messageId; if (!existing || shouldResetTimeline) { return { - ...incoming, - hasRenderableContent: incoming.hasRenderableContent ?? false, - reasoningEvents: incoming.reasoningEvents ?? [], - steps: incoming.steps ?? [], - progressEvents: incoming.progressEvents ?? [], - edits: incoming.edits ?? [], - interactiveEvents: incoming.interactiveEvents ?? [], - rawSdkEventPayloads: incoming.rawSdkEventPayloads ?? [], - liveSessionStatus: incoming.liveSessionStatus ?? null, + ...normalizedIncoming, + hasRenderableContent: normalizedIncoming.hasRenderableContent ?? false, + liveSessionStatus: normalizedIncoming.liveSessionStatus ?? null, }; } - const mergedSteps = mergeActivityArraysLocal(existing.steps, incoming.steps); + const mergedSteps = mergeActivityArraysLocal(existing.steps, normalizedIncoming.steps); const mergedProgressEvents = mergeActivityArraysLocal( existing.progressEvents, - incoming.progressEvents, + normalizedIncoming.progressEvents, ); const mergedReasoningEvents = mergeActivityArraysLocal( existing.reasoningEvents, - incoming.reasoningEvents, + normalizedIncoming.reasoningEvents, ); - const mergedEdits = mergeActivityArraysLocal(existing.edits, incoming.edits); + const mergedEdits = mergeActivityArraysLocal(existing.edits, normalizedIncoming.edits); const mergedInteractiveEvents = mergeActivityArraysLocal( existing.interactiveEvents, - incoming.interactiveEvents, + normalizedIncoming.interactiveEvents, ); return { ...existing, - ...incoming, - messageId: incoming.messageId ?? existing.messageId, + ...normalizedIncoming, + messageId: normalizedIncoming.messageId ?? existing.messageId, content: - incoming.content.trim().length > 0 ? incoming.content : existing.content, + normalizedIncoming.content.trim().length > 0 + ? normalizedIncoming.content + : asStringLocal(existing.content), reasoning: - incoming.reasoning.trim().length > 0 ? incoming.reasoning : existing.reasoning, - steps: mergedSteps ?? existing.steps ?? incoming.steps ?? [], - progressEvents: mergedProgressEvents ?? existing.progressEvents ?? incoming.progressEvents ?? [], - reasoningEvents: mergedReasoningEvents ?? existing.reasoningEvents ?? incoming.reasoningEvents ?? [], - edits: mergedEdits ?? existing.edits ?? incoming.edits ?? [], - interactiveEvents: mergedInteractiveEvents ?? existing.interactiveEvents ?? incoming.interactiveEvents ?? [], + normalizedIncoming.reasoning.trim().length > 0 + ? normalizedIncoming.reasoning + : asStringLocal(existing.reasoning), + steps: mergedSteps ?? existing.steps ?? normalizedIncoming.steps, + progressEvents: mergedProgressEvents ?? existing.progressEvents ?? normalizedIncoming.progressEvents, + reasoningEvents: mergedReasoningEvents ?? existing.reasoningEvents ?? normalizedIncoming.reasoningEvents, + edits: mergedEdits ?? existing.edits ?? normalizedIncoming.edits, + interactiveEvents: mergedInteractiveEvents ?? existing.interactiveEvents ?? normalizedIncoming.interactiveEvents, hasRenderableContent: - existing.hasRenderableContent || incoming.hasRenderableContent || false, + existing.hasRenderableContent || normalizedIncoming.hasRenderableContent || false, hasAssistantFinishSignal: - existing.hasAssistantFinishSignal || incoming.hasAssistantFinishSignal || false, + existing.hasAssistantFinishSignal || normalizedIncoming.hasAssistantFinishSignal || false, hasTerminalStepSignal: - existing.hasTerminalStepSignal || incoming.hasTerminalStepSignal || false, + existing.hasTerminalStepSignal || normalizedIncoming.hasTerminalStepSignal || false, contentStartSeq: existing.contentStartSeq ?? - incoming.contentStartSeq ?? - (incoming.content.trim().length > 0 ? Date.now() : undefined), - plan: incoming.plan ?? existing.plan, - structuredOutput: incoming.structuredOutput ?? existing.structuredOutput, + normalizedIncoming.contentStartSeq ?? + (normalizedIncoming.content.trim().length > 0 ? Date.now() : undefined), + plan: normalizedIncoming.plan ?? existing.plan, + structuredOutput: normalizedIncoming.structuredOutput ?? existing.structuredOutput, liveSessionStatus: - incoming.liveSessionStatus === undefined + normalizedIncoming.liveSessionStatus === undefined ? existing.liveSessionStatus - : incoming.liveSessionStatus, + : normalizedIncoming.liveSessionStatus, rawSdkEventPayloads: dedupeCentralizedDebugPayloads( - [...(existing.rawSdkEventPayloads ?? []), ...(incoming.rawSdkEventPayloads ?? [])], + [...(existing.rawSdkEventPayloads ?? []), ...normalizedIncoming.rawSdkEventPayloads], ), }; } @@ -2941,6 +2958,26 @@ export function appReducer(state: AppState, action: AppAction): AppState { }, sessionId), }; } + case "APPEND_LIVE_EVENT_STREAM_DEBUG": { + const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; + if (!sessionId) { + return state; + } + return { + ...state, + liveEventStreamBySessionId: { + ...(state.liveEventStreamBySessionId ?? {}), + [sessionId]: [ + ...(state.liveEventStreamBySessionId?.[sessionId] ?? []), + action.payload.event, + ], + }, + }; + } + case "CLEAR_LIVE_EVENT_STREAM_DEBUG": + // This field is deliberately a browser-lifetime diagnostic mirror. Do + // not restore it from chatHistory or include it in persistence payloads. + return { ...state, liveEventStreamBySessionId: {} }; case "APPEND_LIVE_TOAST_NOTIFICATION": { const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; if (typeof console !== "undefined") { @@ -2974,6 +3011,10 @@ export function appReducer(state: AppState, action: AppAction): AppState { case "CLEAR_MESSAGES": return { ...state, messages: [] }; case "SET_PROCESSING": + // Defensive normalization for reloaded/legacy webview state. A stream + // bootstrap must never fail merely because a stale state snapshot omitted + // the messages array. + const currentMessages = Array.isArray(state.messages) ? state.messages : []; logger.info("[LOADING][STORE] SET_PROCESSING", { payload: action.payload, currentSessionId: state.currentSessionId, @@ -2982,8 +3023,11 @@ export function appReducer(state: AppState, action: AppAction): AppState { streamingExists: !!state.streaming, hasRenderableContent: state.streaming?.hasRenderableContent, priorIsProcessing: state.isProcessing, - hasMessages: state.messages.length > 0, - lastMessageRole: state.messages.length > 0 ? state.messages[state.messages.length - 1].role : null, + hasMessages: currentMessages.length > 0, + lastMessageRole: + currentMessages.length > 0 + ? currentMessages[currentMessages.length - 1].role + : null, }); // Question popovers are final assistant messages now, not an // interactive-await state. Let new user turns enter processing even when @@ -3083,7 +3127,8 @@ export function appReducer(state: AppState, action: AppAction): AppState { isNewAssistantTurn && !!state.streaming && state.streaming.isActive === false && - (!!state.streaming.messageId || state.streaming.content.trim().length > 0) && + (!!state.streaming.messageId || + asStringLocal(state.streaming.content).trim().length > 0) && state.streaming.messageId !== nextAssistantTurnMessageId; if (shouldClearStaleStreamingSnapshot) { return { diff --git a/webview/shared/src/chat/lib/types.ts b/webview/shared/src/chat/lib/types.ts index fdbc967..eaf3c8d 100644 --- a/webview/shared/src/chat/lib/types.ts +++ b/webview/shared/src/chat/lib/types.ts @@ -853,6 +853,8 @@ export interface AppState { messages: Message[]; messagesBySessionId?: Record; rawSdkEventPayloadsBySessionId?: Record; + /** Unfiltered live stream mirror for debugging; intentionally never persisted. */ + liveEventStreamBySessionId?: Record; liveToastNotificationsBySessionId?: Record; promptQueue: QueueItem[]; queueBySessionId: Record; From e5cc69f37c8b214c4e6321e1ff4265d6c71f97b8 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca Date: Mon, 13 Jul 2026 00:46:59 +0800 Subject: [PATCH 2/3] refactor(chat): subagent projection recovery and session persistence improvements --- package.json | 8 +- src/extension.ts | 2 + src/providers/ChatViewProvider.ts | 416 ++++++++--------- src/providers/chat/StreamEventHandler.ts | 35 +- .../chat/StructuredOutputProcessor.ts | 2 +- src/services/SessionService.ts | 34 +- src/services/SubagentTracker.ts | 95 +++- .../subagents/SubagentProjectionRecovery.ts | 96 ++++ src/utils/InspectorNetworkCompatibility.ts | 73 +++ tests/integration/subagent-tracker.test.ts | 40 ++ tests/providers/chat-send-pipeline.test.mjs | 11 + .../stream-event-orchestration.test.mjs | 6 +- ...tton-interrupted-badge-regression.test.mjs | 23 + ...bagent-parent-response-raw-events.test.mjs | 52 +++ .../subagent-restart-cancellation.test.mjs | 34 ++ ...nt-text-delta-progress-regression.test.mjs | 22 + .../unit/activity-timeline-collapse.test.mjs | 26 ++ .../inspector-network-compatibility.test.mjs | 35 ++ ...ne-finished-response-cancellation.test.mjs | 31 ++ ...line-redundant-content-regression.test.mjs | 18 + ...ntralized-debug-panel-performance.test.mjs | 24 + ...render-source-of-truth-regression.test.mjs | 16 +- ...at-history-performance-regression.test.mjs | 44 +- .../chat-streaming-readability.test.mjs | 15 + .../live-stream-response-rendering.test.mjs | 35 ++ .../webview/session-status-rendering.test.mjs | 25 +- ...eam-event-main-thread-performance.test.mjs | 120 +++++ .../webview/stream-performance-debug.test.mjs | 38 ++ ...agent-initiator-transcript-filter.test.mjs | 82 ++++ ...t-inline-card-block-deduplication.test.mjs | 22 + tests/webview/subagent-modal-layout.test.mjs | 53 +++ .../subagent-raw-hydration-priority.test.mjs | 20 + .../subagent-status-icon-regression.test.mjs | 18 + webview/shared/src/chat/ChatShell.tsx | 439 ++++++++++++------ webview/shared/src/chat/MessageComponents.tsx | 367 ++++++++++----- webview/shared/src/chat/PanelComponents.tsx | 10 +- .../shared/src/chat/StreamingComponents.tsx | 13 +- .../shared/src/chat/SubagentDetailModal.tsx | 272 +++++------ webview/shared/src/chat/ToastOverlay.tsx | 96 ++-- .../activity-steps/ActivityTimelineItem.tsx | 16 + .../activity-steps/BackgroundOutputStep.tsx | 5 +- .../activity-steps/CallOmoAgentStep.tsx | 5 +- .../activity-steps/DiffPreviewStep.tsx | 5 +- .../activity-steps/SearchActivityPreview.tsx | 39 ++ .../activity-steps/SubagentActivityStep.tsx | 36 ++ webview/shared/src/chat/index.css | 9 + .../src/chat/lib/backgroundTaskOwnership.ts | 143 +++++- webview/shared/src/chat/lib/logger.ts | 41 +- webview/shared/src/chat/lib/messageHandler.ts | 273 ++++++----- .../src/chat/lib/sessionProcessing.test.ts | 53 +++ .../shared/src/chat/lib/sessionProcessing.ts | 31 ++ webview/shared/src/chat/lib/store.ts | 167 ++++--- .../chat/lib/subagents/centralExtractor.ts | 20 +- .../src/chat/lib/subagents/dataNormalizer.ts | 6 +- .../src/chat/lib/subagents/eventNormalizer.ts | 28 +- .../src/chat/lib/subagents/hydrationSource.ts | 76 +++ .../src/chat/lib/subagents/stateManager.ts | 172 ++++++- .../shared/src/chat/lib/subagents/types.ts | 25 +- .../src/chat/lib/subagents/uiFormatter.ts | 11 +- .../lib/transcriptMessageClassification.ts | 94 ++++ webview/shared/src/chat/lib/types.ts | 21 +- .../src/chat/lib/usePersistentModalOpen.ts | 18 + .../src/components/ui/StepIndicator.tsx | 6 +- webview/shared/src/config.ts | 2 + 64 files changed, 3049 insertions(+), 1021 deletions(-) create mode 100644 src/services/subagents/SubagentProjectionRecovery.ts create mode 100644 src/utils/InspectorNetworkCompatibility.ts create mode 100644 tests/regression/subagent-parent-response-raw-events.test.mjs create mode 100644 tests/regression/subagent-restart-cancellation.test.mjs create mode 100644 tests/regression/subagent-text-delta-progress-regression.test.mjs create mode 100644 tests/unit/inspector-network-compatibility.test.mjs create mode 100644 tests/webview/activity-timeline-finished-response-cancellation.test.mjs create mode 100644 tests/webview/centralized-debug-panel-performance.test.mjs create mode 100644 tests/webview/live-stream-response-rendering.test.mjs create mode 100644 tests/webview/stream-event-main-thread-performance.test.mjs create mode 100644 tests/webview/stream-performance-debug.test.mjs create mode 100644 tests/webview/subagent-initiator-transcript-filter.test.mjs create mode 100644 tests/webview/subagent-inline-card-block-deduplication.test.mjs create mode 100644 tests/webview/subagent-modal-layout.test.mjs create mode 100644 tests/webview/subagent-raw-hydration-priority.test.mjs create mode 100644 tests/webview/subagent-status-icon-regression.test.mjs create mode 100644 webview/shared/src/chat/components/activity-steps/ActivityTimelineItem.tsx create mode 100644 webview/shared/src/chat/components/activity-steps/SearchActivityPreview.tsx create mode 100644 webview/shared/src/chat/components/activity-steps/SubagentActivityStep.tsx create mode 100644 webview/shared/src/chat/lib/subagents/hydrationSource.ts create mode 100644 webview/shared/src/chat/lib/transcriptMessageClassification.ts create mode 100644 webview/shared/src/chat/lib/usePersistentModalOpen.ts diff --git a/package.json b/package.json index 1e3cfd7..9ce4209 100644 --- a/package.json +++ b/package.json @@ -251,10 +251,10 @@ "description": "Fraction of model context limit at which auto-compaction fires (0.9 = 90%)" }, "opencode.debug.suppressConsoleOutput": { - "type": "boolean", - "default": false, - "description": "Suppress all extension host console output (console.log, console.warn, console.error, console.debug, console.info). Useful for silencing debug logging in terminal and browser DevTools." - } + "type": "boolean", + "default": false, + "description": "Suppress all extension host console output (console.log, console.warn, console.error, console.debug, console.info). Useful for silencing debug logging in terminal and browser DevTools." + } } } }, diff --git a/src/extension.ts b/src/extension.ts index 154bfb1..699707f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ */ import * as vscode from "vscode"; +import { installInspectorNetworkCompatibility } from "./utils/InspectorNetworkCompatibility"; import { OpencodeServerManager } from "./services/OpencodeServerManager"; import { SessionService } from "./services/SessionService"; import { ChatViewProvider } from "./providers/ChatViewProvider"; @@ -148,6 +149,7 @@ function setupConsoleSuppression(context: vscode.ExtensionContext): void { } export async function activate(context: vscode.ExtensionContext) { + installInspectorNetworkCompatibility(); setupConsoleSuppression(context); const activationStartTime = Date.now(); diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 9a81baf..accaad3 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -211,6 +211,11 @@ type MessageChangeSummary = { */ export class ChatViewProvider implements vscode.WebviewViewProvider, FileThemeProcessorObserver { + private static readonly STREAM_WEBVIEW_FLUSH_INTERVAL_MS = 50; + private static readonly MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH = 8; + private static readonly STREAM_WEBVIEW_BACKLOG_YIELD_MS = 16; + private static readonly MAX_STREAM_WEBVIEW_TOOL_OUTPUT_CHARS = 16_384; + private static readonly SUBAGENT_SNAPSHOT_PREFIX = "opencode.session.subagents."; private static readonly COMPACTION_VIEW_STATE_PREFIX = @@ -234,6 +239,8 @@ export class ChatViewProvider /** Service for monitoring AI platform quota usage */ private quotaService: QuotaService; private subagentTracker: SubagentTracker; + /** Sessions whose persisted live subagents were finalized for this host instance. */ + private recoveredSubagentSessions = new Set(); /** Provider for managing configuration files */ private configFilesProvider: ConfigFilesProvider; @@ -287,6 +294,7 @@ export class ChatViewProvider private activeStreamSessionId: string | undefined; private pendingStreamWebviewEvents: Array<{ event: unknown; sessionId?: string }> = []; private streamWebviewFlushTimer: ReturnType | undefined; + private lastStreamPerformanceLogAt = 0; /** * Debug-only mirror of every event that reaches this provider. This is sent * to the webview but deliberately never enters SessionService persistence. @@ -350,6 +358,10 @@ export class ChatViewProvider this.pendingStreamWebviewEvents.push({ event, sessionId }); if (flushImmediately) { + // Lifecycle events should start delivery immediately, but must not + // bypass the per-message cap. A terminal event can arrive behind a large + // tool/subagent burst; posting that entire backlog in one IPC message + // blocks the webview and makes scrolling freeze. this.flushStreamWebviewEvents(); return; } @@ -360,20 +372,76 @@ export class ChatViewProvider this.streamWebviewFlushTimer = setTimeout(() => { this.flushStreamWebviewEvents(); - }, 32); + }, ChatViewProvider.STREAM_WEBVIEW_FLUSH_INTERVAL_MS); + } + + private truncateStreamToolTextForWebview(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + const limit = ChatViewProvider.MAX_STREAM_WEBVIEW_TOOL_OUTPUT_CHARS; + if (value.length <= limit) { + return value; + } + return `${value.slice(0, limit)}\n\n[Output truncated in the live chat view: ${value.length - limit} characters omitted]`; + } + + private buildWebviewStreamEvent(event: unknown, sessionId: string | undefined): unknown { + const eventRecord = this.asRecord(event); + if (!eventRecord) { + return event; + } + const trimPart = (value: unknown): unknown => { + const part = this.asRecord(value); + if (!part) return value; + const state = this.asRecord(part.state); + const metadata = this.asRecord(part.metadata); + const trimmedState = state + ? { + ...state, + output: this.truncateStreamToolTextForWebview(state.output), + result: this.truncateStreamToolTextForWebview(state.result), + } + : state; + const trimmedMetadata = metadata + ? { + ...metadata, + preview: this.truncateStreamToolTextForWebview(metadata.preview), + } + : metadata; + return { + ...part, + output: this.truncateStreamToolTextForWebview(part.output), + state: trimmedState, + metadata: trimmedMetadata, + }; + }; + const properties = this.asRecord(eventRecord.properties); + return { + ...eventRecord, + sessionId, + part: trimPart(eventRecord.part), + properties: properties + ? { ...properties, part: trimPart(properties.part) } + : eventRecord.properties, + }; } private flushStreamWebviewEvents(): void { + const startedAt = performance.now(); if (this.streamWebviewFlushTimer) { clearTimeout(this.streamWebviewFlushTimer); this.streamWebviewFlushTimer = undefined; } - const pending = this.pendingStreamWebviewEvents; + const pending = this.pendingStreamWebviewEvents.splice( + 0, + ChatViewProvider.MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH, + ); if (pending.length === 0) { return; } - this.pendingStreamWebviewEvents = []; + const hasBacklog = this.pendingStreamWebviewEvents.length > 0; if (pending.length === 1) { const item = pending[0]; @@ -382,6 +450,8 @@ export class ChatViewProvider event: item.event, sessionId: item.sessionId, }); + this.logStreamPerformance("provider-webview-flush", startedAt, 1); + this.scheduleStreamWebviewBacklogFlush(hasBacklog); return; } @@ -392,6 +462,33 @@ export class ChatViewProvider sessionId: item.sessionId, })), }); + this.logStreamPerformance("provider-webview-flush", startedAt, pending.length); + this.scheduleStreamWebviewBacklogFlush(hasBacklog); + } + + private scheduleStreamWebviewBacklogFlush(hasBacklog: boolean): void { + if (!hasBacklog || this.streamWebviewFlushTimer) { + return; + } + this.streamWebviewFlushTimer = setTimeout(() => { + this.flushStreamWebviewEvents(); + }, ChatViewProvider.STREAM_WEBVIEW_BACKLOG_YIELD_MS); + } + + private logStreamPerformance( + metric: string, + startedAt: number, + batchSize: number, + ): void { + const now = Date.now(); + if (now - this.lastStreamPerformanceLogAt < 250) { + return; + } + this.lastStreamPerformanceLogAt = now; + this.logger.info(`[STREAM-PERF] ${metric}`, { + batchSize, + durationMs: Number((performance.now() - startedAt).toFixed(2)), + }); } private enqueueLiveEventDebugEvent( @@ -956,7 +1053,6 @@ export class ChatViewProvider interactiveSubmit?: boolean; avoidAbortIfProcessing?: boolean; forceSendNow?: boolean; - delivery?: "immediate" | "deferred"; }, ): Promise { const text = typeof payload.text === "string" ? payload.text.trim() : ""; @@ -1026,7 +1122,6 @@ export class ChatViewProvider : [], agent: payload.agent ?? null, interactiveSubmit: payload.interactiveSubmit === true, - delivery: payload.delivery ?? null, }); const now = Date.now(); if ( @@ -1053,12 +1148,11 @@ export class ChatViewProvider } const isMainTurnProcessing = this.isSessionMainTurnProcessing(sessionId); - // Keep mode ownership explicit. The webview marks active-assistant composer - // sends with payload.delivery="deferred" so OpenCode's agent loop can enqueue - // them server-side. Do not auto-convert a normal send-now request into steer - // or the extension QueueManager from processing flags; those flags can include - // stale or child/subagent work after the top-level assistant block is already - // complete, which makes the next user message appear in the wrong queue path. + // Keep mode ownership explicit. Do not auto-convert a normal send-now + // request into steer or the extension QueueManager from processing flags; + // those flags can include stale or child/subagent work after the top-level + // assistant block is already complete, which makes the next user message + // appear in the wrong queue path. const effectiveMode = mode; this.logger.debug('[MessageFlow] Mode resolution', { @@ -1067,7 +1161,6 @@ export class ChatViewProvider sessionId, isProcessing: isMainTurnProcessing, effectiveProcessing: this.getEffectiveProcessingSessionIds().includes(sessionId), - delivery: payload.delivery, forceSendNow: payload.forceSendNow }); @@ -1091,34 +1184,6 @@ export class ChatViewProvider }); } - // For normal sends, bypass queue persistence entirely so the queue panel - // does not show transient "queued" items when there is no active backlog. - if ( - effectiveMode === "send-now" && - payload.delivery === "deferred" - ) { - const acceptedPrompt = await this.sendDeferredPromptToAgentLoop(sessionId, { - text, - files: payload.files, - contexts: payload.contexts, - images: payload.images, - agent: payload.agent, - clientRequestId: clientRequestId || undefined, - }); - this.view?.webview.postMessage({ - type: "deferredPromptAccepted", - sessionId, - clientRequestId: clientRequestId || undefined, - text, - files: payload.files, - contexts: payload.contexts, - images: payload.images, - agent: payload.agent, - message: acceptedPrompt, - }); - return; - } - if (effectiveMode === "send-now") { this.logger.debug('[MessageFlow] Queue bypass - sending directly', { sessionId, @@ -1618,6 +1683,7 @@ export class ChatViewProvider rawEventStream: { events: sessionHistory.events }, subagents: sessionHistory.subagents, + subagentsRecoveredAfterRestart: sessionHistory.subagentsRecoveredAfterRestart, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); await this.compactionManager.sendCompactionViewStateForMessages( @@ -2350,9 +2416,9 @@ export class ChatViewProvider // actively processing. If the main turn has already finished (not in // processingSessionIds), dangling "pending"/"running" subagents from the // previous turn must NOT re-inflate the effective list — otherwise the - // webview sees the session as processing and marks the next user message - // as delivery="deferred", causing it to be silently swallowed by the - // agent loop instead of starting a new turn. + // webview sees the session as busy and defers the next user message, + // causing it to wait behind a non-existent turn instead of starting + // a new one immediately. if (this.processingSessionIds.size > 0) { for (const sessionId of this.subagentTracker.getActiveProcessingSessionIds()) { // Only include if the parent session is itself still processing @@ -2931,6 +2997,7 @@ export class ChatViewProvider rawEventStream: { events: sessionHistory.events }, subagents: sessionHistory.subagents, + subagentsRecoveredAfterRestart: sessionHistory.subagentsRecoveredAfterRestart, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); await this.sendPersistedCompactionViewState(currentSession.id); @@ -2994,7 +3061,6 @@ export class ChatViewProvider contexts: message.contexts, images: message.images, agent: message.agent, - delivery: message.delivery === "deferred" ? "deferred" : undefined, // Interactive popover submits should behave like a normal direct // user send, even if stale processing flags briefly linger from // the preceding question turn. @@ -3825,6 +3891,7 @@ export class ChatViewProvider rawEventStream: { events: sessionHistory.events }, subagents: sessionHistory.subagents, + subagentsRecoveredAfterRestart: sessionHistory.subagentsRecoveredAfterRestart, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); } catch (err) { @@ -4068,16 +4135,37 @@ export class ChatViewProvider } } - // Keep an unfiltered, client-only mirror for diagnosing why a live event - // is absent from the persisted event stream. This path stays ahead of all - // persistence/presentation gates and never calls SessionService. - this.enqueueLiveEventDebugEvent( - event, - subagentParentSessionId || - eventSessionId || - this.activeStreamSessionId || - this.currentSessionId, - ); + // The client-only mirror is only needed for live-only UI events that + // deliberately bypass the persisted transcript. Mirroring every token + // doubled IPC and reducer work during normal streams. + if ( + eventType === "tui.show" || + eventType === "tui.toast.show" || + eventType === "session.status" + ) { + this.enqueueLiveEventDebugEvent( + event, + subagentParentSessionId || + eventSessionId || + this.activeStreamSessionId || + this.currentSessionId, + ); + } + if (eventType === "tui.show" || eventType === "tui.toast.show") { + this.logger.info("[LIVE-TOAST][HOST] captured", { + eventType, + eventId: typeof (event as Record).id === "string" + ? (event as Record).id + : undefined, + eventSessionId, + resolvedSessionId: + subagentParentSessionId || + eventSessionId || + this.activeStreamSessionId || + this.currentSessionId, + properties: (event as Record).properties, + }); + } // Persist the raw event before any presentation/session-processing gate. // Subagent tracker updates intentionally run ahead of those gates; keeping @@ -4158,7 +4246,8 @@ export class ChatViewProvider eventType === "question.asked" || eventType === "message.updated" || eventType === "message.completed" || - eventType === "session.diff"; + eventType === "session.diff" || + eventType === "session.status"; if ( eventSessionId && !shouldBypassProcessingGate && @@ -4330,7 +4419,10 @@ export class ChatViewProvider }); } - const eventForWebview = { ...enrichedEvent, sessionId: resolvedSessionId }; + const eventForWebview = this.buildWebviewStreamEvent( + enrichedEvent, + resolvedSessionId, + ); const shouldFlushWebviewImmediately = eventType === "question.asked" || eventType === "message.completed" || @@ -4352,7 +4444,7 @@ export class ChatViewProvider ); if (resolvedSessionId) { const centralizedEventPayload = { - ...eventForWebview, + ...enrichedEvent, sessionId: resolvedSessionId, }; if (this.shouldVerboseStreamDebug()) { @@ -4656,13 +4748,22 @@ export class ChatViewProvider events: unknown[]; subagents: import("../services/SessionService").CentralizedSubagentProjection; messages: any[]; + subagentsRecoveredAfterRestart: boolean; }> { this.logger.debug(`[loadCentralizedRenderableHistory] START sessionId=${sessionId}`); const start = Date.now(); try { - const rawSessionPayloads = await this.sessionService.loadCentralizedSessionData( + let rawSessionPayloads = await this.sessionService.loadCentralizedSessionData( sessionId, ); + if (!this.recoveredSubagentSessions.has(sessionId)) { + rawSessionPayloads = { + ...rawSessionPayloads, + subagents: await this.sessionService.cancelStaleSubagentsAfterExtensionRestart(sessionId), + }; + this.recoveredSubagentSessions.add(sessionId); + } + const subagentsRecoveredAfterRestart = this.recoveredSubagentSessions.has(sessionId); const rawArray = Array.isArray(rawSessionPayloads.rawEventStream.events) ? rawSessionPayloads.rawEventStream.events @@ -4679,6 +4780,17 @@ export class ChatViewProvider safeRawSdkEventPayloads, sessionId, ); + const cancelledDetailsById = new Map( + Object.entries(rawSessionPayloads.subagents.detailsById) + .filter(([, detail]) => detail?.status === "cancelled"), + ); + for (const message of messages) { + if (!Array.isArray(message?.subagents)) continue; + message.subagents = message.subagents.map((subagent: any) => { + const cancelledDetail = cancelledDetailsById.get(subagent?.id); + return cancelledDetail ? { ...subagent, ...cancelledDetail } : subagent; + }); + } this.logger.debug(`[loadCentralizedRenderableHistory] Processed ${messages.length} messages in ${Date.now() - t2}ms`); this.logger.debug("[CENTRALIZED-TAPE][HOST] loaded_renderable_history", { @@ -4691,6 +4803,7 @@ export class ChatViewProvider events: safeRawSdkEventPayloads, subagents: rawSessionPayloads.subagents, messages, + subagentsRecoveredAfterRestart, }; } catch (err: any) { this.logger.error(`[loadCentralizedRenderableHistory] ERROR: ${err.message}`, { stack: err.stack }); @@ -4698,6 +4811,7 @@ export class ChatViewProvider events: [], subagents: { summariesByParentMessageId: {}, detailsById: {} }, messages: [], + subagentsRecoveredAfterRestart: false, }; } } @@ -5066,147 +5180,6 @@ export class ChatViewProvider return lines.join("\n"); } - - private buildDeferredSdkPrompt(options: { - text: string; - files?: string[]; - contexts?: any[]; - images?: any[]; - agent?: string; - }): { text: string; files?: any[]; agents?: any[] } { - const promptFiles: any[] = []; - for (const filePath of options.files ?? []) { - if (typeof filePath !== "string" || !filePath.trim()) { - continue; - } - promptFiles.push({ - uri: path.isAbsolute(filePath) ? vscode.Uri.file(filePath).toString() : filePath, - mime: "text/plain", - name: filePath, - }); - } - for (const context of options.contexts ?? []) { - const filePath = typeof context?.file === "string" ? context.file : ""; - if (!filePath || filePath.startsWith("resource:")) { - continue; - } - const content = typeof context?.content === "string" ? context.content : ""; - if (content) { - // Code selection: use data URI so the server only sees the selected lines, - // not the whole file from disk. Without this, the server resolves the raw - // file path and the AI reads the entire file instead of the selection. - const dataUri = `data:text/plain;base64,${Buffer.from( - content, - "utf-8", - ).toString("base64")}`; - const lineInfo = - typeof context?.lineInfo === "string" ? context.lineInfo : ""; - const nameWithLine = - filePath && lineInfo ? `${filePath}:${lineInfo}` : filePath; - promptFiles.push({ - uri: dataUri, - mime: "text/plain", - name: nameWithLine, - source: { - type: "file", - path: filePath, - text: { - value: content, - start: 0, - end: content.length, - }, - lineInfo, - languageId: - typeof context?.languageId === "string" ? context.languageId : "", - }, - }); - } else { - promptFiles.push({ - uri: path.isAbsolute(filePath) - ? vscode.Uri.file(filePath).toString() - : filePath, - mime: "text/plain", - name: filePath, - }); - } - } - for (const image of options.images ?? []) { - const dataUrl = - typeof image === "string" - ? image - : typeof image?.dataUrl === "string" - ? image.dataUrl - : ""; - if (!dataUrl) { - continue; - } - promptFiles.push({ - uri: dataUrl, - mime: dataUrl.match(/^data:([^;]+);/)?.[1] ?? "image/png", - name: - typeof image === "object" && typeof image?.filename === "string" - ? image.filename - : "image", - }); - } - - return { - text: options.text, - ...(promptFiles.length > 0 ? { files: promptFiles } : {}), - ...(options.agent ? { agents: [{ name: options.agent }] } : {}), - }; - } - - private async sendDeferredPromptToAgentLoop( - sessionId: string, - options: { - text: string; - files?: string[]; - contexts?: any[]; - images?: any[]; - agent?: string; - clientRequestId?: string; - }, - ): Promise { - const client = await this.serverManager.ensureRunning(); - const promptFn = (client as any)?.v2?.session?.prompt; - if (typeof promptFn !== "function") { - this.logger.warn("[MessageFlow] SDK v2 deferred prompt unavailable; falling back to direct send", { - sessionId, - clientRequestId: options.clientRequestId, - }); - await this.handleSendMessage( - options.text, - options.files, - options.contexts, - options.images, - options.agent, - false, - undefined, - false, - undefined, - undefined, - { clientRequestId: options.clientRequestId }, - ); - return undefined; - } - - const workspaceDirectory = this.getWorkspaceDirectory(); - const prompt = this.buildDeferredSdkPrompt(options); - // This is OpenCode's server-side prompt delivery, not the extension's - // visible QueueManager. While the assistant is responding, `delivery: - // "deferred"` appends the user's next prompt to the agent loop so OpenCode - // runs it after the current turn instead of us showing a local queue item or - // aborting the current response. - const response = await promptFn.call((client as any).v2.session, { - sessionID: sessionId, - ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), - prompt, - delivery: "deferred", - }); - return getSdkResponseData(response) ?? (response as any)?.data ?? response; - } - // PROMPT-OWNERSHIP: do not modify — transport-only path private async promptWithStructuredOutput( client: any, @@ -5812,14 +5785,15 @@ export class ChatViewProvider private normalizeSubagentStatus( value: unknown, - ): "pending" | "running" | "done" | "error" | "orphaned" { + ): "pending" | "running" | "done" | "error" | "orphaned" | "cancelled" { const status = this.firstNonEmptyString(value)?.toLowerCase(); if ( status === "pending" || status === "running" || status === "done" || status === "error" || - status === "orphaned" + status === "orphaned" || + status === "cancelled" ) { return status; } @@ -8109,9 +8083,28 @@ export class ChatViewProvider return; } + // A stop is a local user decision first. Do not leave the webview + // waiting for either the abort HTTP request or its terminal SSE event: + // both can fail while the server/stream is unhealthy. Finalize locally + // so late events for this request are obsolete and cannot keep the UI + // loading forever. + this.processingSessionIds.delete(resolvedSessionId); + this.sessionsWithFileChangeEvidence.delete(resolvedSessionId); + if (this.activeStreamSessionId === resolvedSessionId) { + this.activeStreamSessionId = undefined; + } + this.sendProcessingSessionsUpdate(); + + if (!options?.suppressWebviewNotification) { + this.view?.webview.postMessage({ + type: "stopRequestHandled", + sessionId: resolvedSessionId, + }); + } + const client = this.serverManager.getClient(); if (!client) { - this.logger.warn("stopRequest skipped: no client available", { + this.logger.warn("stopRequest finalized locally: no client available", { sessionId: resolvedSessionId, }); return; @@ -8122,19 +8115,9 @@ export class ChatViewProvider }); const workspaceDirectory = this.getWorkspaceDirectory(); - - // Send the abort signal to the backend. We do NOT clear the local - // processingSessionIds or send a synthetic stopRequestHandled payload. - // - // ARCHITECTURE (Single Source of Truth): - // The UI shouldn't manually synthetically end the AI's turn. Instead, - // we fire this abort and let the Centralized Tape (event stream) handle it natively. - // When the server successfully kills its process, it will emit a final - // terminal lifecycle event (`message.updated` with aborted state, or - // `session.status: idle`) over the stream. - // The stream handler catches this terminal event, updates the data layer, - // removes the session from processingSessionIds, and React will - // sync the UI correctly. + + // Abort remains best-effort after local finalization. An error here is + // diagnostic only; the user has already stopped this turn in the UI. client.session.abort({ sessionID: resolvedSessionId, ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), @@ -8145,8 +8128,7 @@ export class ChatViewProvider }, error as Error); }); } finally { - // Intentionally empty. - // Turn ending is handled reactively by the data stream, not here. + // Local finalization above intentionally does not depend on stream health. } } diff --git a/src/providers/chat/StreamEventHandler.ts b/src/providers/chat/StreamEventHandler.ts index 7c1f47d..0935f81 100644 --- a/src/providers/chat/StreamEventHandler.ts +++ b/src/providers/chat/StreamEventHandler.ts @@ -19,11 +19,18 @@ type PendingStreamEvent = { sessionId: string | undefined; }; +// 20 presentation batches/sec remains visually smooth while leaving main +// thread time for scrolling and input during 30-60 event/sec token streams. +const STREAM_WEBVIEW_FLUSH_INTERVAL_MS = 50; +const MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH = 8; +const STREAM_WEBVIEW_BACKLOG_YIELD_MS = 16; + export class StreamEventHandler { private streamStartTime?: number; private eventCount = 0; private pendingEvents: PendingStreamEvent[] = []; private flushRafId: ReturnType | null = null; + private lastStreamPerformanceLogAt = 0; constructor( private structuredOutputProcessor: StructuredOutputProcessor, @@ -105,15 +112,18 @@ export class StreamEventHandler { } // Buffer event for batched delivery. During active streaming this - // coalesces high-frequency text chunks (30-60/sec) into ~1 postMessage - // per animation frame, preventing the VS Code webview IPC boundary from + // coalesces high-frequency text chunks (30-60/sec) into bounded batches, + // preventing the VS Code webview IPC boundary from // becoming a scroll-jank bottleneck. this.pendingEvents.push({ enrichedEvent, event, sessionId }); if (this.isTerminalEvent(eventType)) { this.flushPendingEvents(); } else if (this.flushRafId === null) { - this.flushRafId = setTimeout(() => this.flushPendingEvents(), 16); + this.flushRafId = setTimeout( + () => this.flushPendingEvents(), + STREAM_WEBVIEW_FLUSH_INTERVAL_MS, + ); } this.persistRawSdkEventPayload(sessionId, event, rawEvent); @@ -251,14 +261,15 @@ export class StreamEventHandler { } private flushPendingEvents(): void { + const startedAt = performance.now(); if (this.flushRafId !== null) { clearTimeout(this.flushRafId); this.flushRafId = null; } - const batch = this.pendingEvents; + const batch = this.pendingEvents.splice(0, MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH); if (batch.length === 0) return; - this.pendingEvents = []; + const hasBacklog = this.pendingEvents.length > 0; if (batch.length === 1) { const { enrichedEvent, event, sessionId } = batch[0]; @@ -276,6 +287,20 @@ export class StreamEventHandler { })), }); } + const now = Date.now(); + if (now - this.lastStreamPerformanceLogAt >= 250) { + this.lastStreamPerformanceLogAt = now; + this.logger.info("[STREAM-PERF] host-stream-flush", { + batchSize: batch.length, + durationMs: Number((performance.now() - startedAt).toFixed(2)), + }); + } + if (hasBacklog && this.flushRafId === null) { + this.flushRafId = setTimeout( + () => this.flushPendingEvents(), + STREAM_WEBVIEW_BACKLOG_YIELD_MS, + ); + } } /** diff --git a/src/providers/chat/StructuredOutputProcessor.ts b/src/providers/chat/StructuredOutputProcessor.ts index 16d84d4..ad55634 100644 --- a/src/providers/chat/StructuredOutputProcessor.ts +++ b/src/providers/chat/StructuredOutputProcessor.ts @@ -504,7 +504,7 @@ export class StructuredOutputProcessor { success: "done", failed: "error", error: "error", - cancelled: "error", + cancelled: "cancelled", canceled: "error", pending: "pending", queued: "pending", diff --git a/src/services/SessionService.ts b/src/services/SessionService.ts index 6b3f442..764f4b6 100644 --- a/src/services/SessionService.ts +++ b/src/services/SessionService.ts @@ -62,6 +62,7 @@ import { } from "../shared/centralizedDebugPayloadFilter"; import { createPlainObjectSnapshot } from "../shared/createPlainObjectSnapshot"; import type { SubagentUpdatePayload } from "./SubagentTracker"; +import { recoverSubagentProjectionAfterRestart } from "./subagents/SubagentProjectionRecovery"; const log = createLogger(LoggingCategories.SESSION_SERVICE); const MAX_CACHED_MESSAGES_PER_SESSION = 200; @@ -379,11 +380,16 @@ function compactSubagentForPersistence(subagent: unknown): unknown { .slice(-MAX_COMPACT_SUBAGENT_EVENTS) .map((item) => compactReasoningEventForPersistence(item)); } - if (Array.isArray(rec.conversationEvents)) { - compact.conversationEvents = rec.conversationEvents - .slice(-MAX_COMPACT_SUBAGENT_EVENTS) - .map((item) => sanitizeForPersistence(item)); - } + if (Array.isArray(rec.conversationEvents)) { + compact.conversationEvents = rec.conversationEvents + .slice(-MAX_COMPACT_SUBAGENT_EVENTS) + .map((item) => sanitizeForPersistence(item)); + } + if (Array.isArray(rec.rawEvents)) { + compact.rawEvents = rec.rawEvents + .slice(-MAX_COMPACT_SUBAGENT_EVENTS) + .map((item) => sanitizeForPersistence(item)); + } if (Array.isArray(rec.progressEvents)) { compact.progressEvents = rec.progressEvents .slice(-MAX_COMPACT_SUBAGENT_EVENTS) @@ -2330,6 +2336,24 @@ export class SessionService { }; } + /** + * Finalize subagents left live by a previous extension-host instance. + * + * The tracker itself is in-memory. A persisted pending/running projection + * cannot be live after activation, so make that terminal state durable + * before replaying the session into the webview. + */ + async cancelStaleSubagentsAfterExtensionRestart( + sessionId: string, + ): Promise { + await this.updateCentralizedSessionData(sessionId, (current) => ({ + ...current, + subagents: recoverSubagentProjectionAfterRestart(current.subagents), + })); + + return this.centralizedSubagentProjectionCache.get(sessionId) || this.emptySubagentProjection(); + } + /** Merge a live subagent update into the centralized session payload. */ async persistCentralizedSubagentProjection( sessionId: string, diff --git a/src/services/SubagentTracker.ts b/src/services/SubagentTracker.ts index 104e96f..5bd48e5 100644 --- a/src/services/SubagentTracker.ts +++ b/src/services/SubagentTracker.ts @@ -23,7 +23,8 @@ export type SubagentStatus = | "running" | "done" | "error" - | "orphaned"; + | "orphaned" + | "cancelled"; export type SubagentReference = { messageID?: string; @@ -89,6 +90,8 @@ export type SubagentSummary = { }; export type SubagentDetail = SubagentSummary & { + /** Canonical SDK event tape; same shape as Message.rawSdkEventPayloads. */ + rawEvents: unknown[]; thinkingEvents: SubagentThinkingEvent[]; conversationEvents: SubagentConversationEvent[]; progressEvents: SubagentProgressEvent[]; @@ -129,6 +132,7 @@ const MAX_TIMELINE_EVENTS = 200; const MAX_PROGRESS_EVENTS = 200; const MAX_THINKING_EVENTS = 200; const MAX_CONVERSATION_EVENTS = 400; +const MAX_RAW_EVENTS = 400; function asRecord(value: unknown): UnknownRecord | null { return typeof value === "object" && value !== null @@ -294,6 +298,28 @@ function selectUserFacingErrorMessage( return incomingQuality >= existingQuality ? incoming : existing || incoming; } +/** + * A child session can fail before it emits its first message.updated event. + * In that case session.created usually has no model metadata and the card is + * temporarily labelled with the parent's selected model. A provider's + * "Model not found" error, however, names the exact model the child actually + * attempted to use, so it is the authoritative metadata for that failed run. + */ +function modelFromNotFoundError( + errorText: string, +): { providerID: string; modelID: string } | undefined { + const match = /\bmodel\s+not\s+found\s*:\s*([^\s/:]+)\/([^\s,?.]+)/i.exec( + errorText, + ); + if (!match) { + return undefined; + } + + const providerID = match[1].trim(); + const modelID = match[2].trim(); + return providerID && modelID ? { providerID, modelID } : undefined; +} + function toTimestamp(value: unknown, fallback = Date.now()): number { const n = asNumber(value); if (typeof n === "number" && Number.isFinite(n)) { @@ -363,7 +389,7 @@ function joinConversationText(previous: string, incoming: string): string { !/\s/.test(prevChar) && !/\s/.test(nextChar) && !/^[,.;:!?)}\]]/.test(incoming) && - !/[([{]$/ .test(prevChar); + !/[([{<]$/ .test(prevChar); return needsSpace ? `${previous} ${incoming}` : `${previous}${incoming}`; } @@ -409,6 +435,7 @@ function cloneSummary(summary: SubagentSummary): SubagentSummary { function cloneDetail(detail: SubagentDetail): SubagentDetail { return { ...cloneSummary(detail), + rawEvents: [...detail.rawEvents], thinkingEvents: detail.thinkingEvents.map((event) => ({ ...event })), conversationEvents: detail.conversationEvents.map((event) => ({ ...event })), progressEvents: detail.progressEvents.map((event) => ({ ...event })), @@ -567,6 +594,15 @@ export class SubagentTracker { return null; } + // Preserve the source tape with the derived detail so child sessions can + // reuse raw-event based chat components without reshaping their payload. + for (const detailId of changedDetails) { + const detail = this.detailsById.get(detailId); + if (detail) { + this.appendRawEvent(detail, event); + } + } + return this.buildUpdatePayload(changedParents, changedDetails); } @@ -701,16 +737,17 @@ export class SubagentTracker { statusValue === "running" || statusValue === "done" || statusValue === "error" || - statusValue === "orphaned" + statusValue === "orphaned" || + statusValue === "cancelled" ? (statusValue as SubagentStatus) : statusValue === "completed" || statusValue === "finished" || statusValue === "success" ? "done" - : statusValue === "failed" || - statusValue === "cancelled" || - statusValue === "canceled" + : statusValue === "failed" ? "error" + : statusValue === "canceled" + ? "cancelled" : "pending"; const references = Array.isArray(rec.references) @@ -782,6 +819,10 @@ export class SubagentTracker { .filter((item): item is SubagentConversationEvent => !!item) : []; + const rawEvents = Array.isArray(rec.rawEvents) + ? rec.rawEvents.slice(-MAX_RAW_EVENTS) + : []; + const progressEvents = Array.isArray(rec.progressEvents) ? rec.progressEvents .map((item) => { @@ -860,6 +901,7 @@ export class SubagentTracker { status, latestActivity: asString(rec.latestActivity) || "Loaded from history", references, + rawEvents, thinkingEvents, conversationEvents, progressEvents, @@ -1127,6 +1169,13 @@ export class SubagentTracker { } } + private appendRawEvent(detail: SubagentDetail, event: unknown): void { + detail.rawEvents = clampEvents( + [...detail.rawEvents, event], + MAX_RAW_EVENTS, + ); + } + private handleMessagePartUpdated( properties: UnknownRecord, changedParents: Set, @@ -1176,6 +1225,7 @@ export class SubagentTracker { latestActivity: partType === "agent" ? "Background agent requested" : "Subagent requested", references: [], + rawEvents: [], thinkingEvents: [], conversationEvents: [], progressEvents: [], @@ -1271,7 +1321,7 @@ export class SubagentTracker { detail.latestActivity = thinkingText.trim().slice(0, 120); } - const progress = this.extractProgressFromPart(part, properties, createdAt); + const progress = this.extractProgressFromPart(part, createdAt); if (progress) { this.pushProgress(detail, { ...progress, @@ -1546,6 +1596,7 @@ export class SubagentTracker { providerID, modelID, references: [], + rawEvents: [], thinkingEvents: [], conversationEvents: [], progressEvents: [], @@ -1724,6 +1775,16 @@ export class SubagentTracker { errorText = selectUserFacingErrorMessage(detail.errorText, errorText); + // Do not leave a parent-model fallback (for example, glm-5.2) on a card + // whose child session definitively failed while requesting another model + // (for example, opencode/gpt-5-nano). This also covers failures that occur + // before the child has emitted message.updated metadata. + const failedModel = modelFromNotFoundError(errorText); + if (failedModel) { + detail.providerID = failedModel.providerID; + detail.modelID = failedModel.modelID; + } + // If we still have a generic error, try to construct a more informative one. // Do not use this fallback for an internal stack trace: it is intentionally // downgraded above so it cannot overwrite the server's actionable message. @@ -1790,7 +1851,6 @@ export class SubagentTracker { private extractProgressFromPart( part: UnknownRecord, - properties: UnknownRecord, createdAt: number, ): Omit | null { const partType = asString(part.type).toLowerCase(); @@ -1897,20 +1957,11 @@ export class SubagentTracker { }; } - const delta = asString(properties.delta); - if (partType && delta) { - const deltaLabel = sanitizeActivityLabel(delta); - if (!deltaLabel) { - return null; - } - return { - id: `${asString(part.id) || partType}:${createdAt}`, - title: `${partType}: ${deltaLabel}`, - status: "pending", - createdAt, - callID, - }; - } + // Text/reasoning deltas are content, not progress. The generic fallback + // used to turn each token into a step (for example, `text: < text: + // manifest text: .json`), duplicating the same payload in the activity + // timeline and corrupting its label. Only explicit progress-bearing part + // types above may create progress events. return null; } diff --git a/src/services/subagents/SubagentProjectionRecovery.ts b/src/services/subagents/SubagentProjectionRecovery.ts new file mode 100644 index 0000000..d11ed80 --- /dev/null +++ b/src/services/subagents/SubagentProjectionRecovery.ts @@ -0,0 +1,96 @@ +/** + * Persistence-only recovery for the legacy subagent UI projection. + * + * Raw SDK events remain authoritative. This module merely prevents a cached + * projection from falsely presenting pre-restart work as live while older + * session data is still supported. + */ +type UnknownRecord = Record; + +export type SubagentProjectionLike = { + summariesByParentMessageId: Record; + detailsById: Record; +}; + +function isRecord(value: unknown): value is UnknownRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isLegacyTextStep(value: unknown): boolean { + return typeof value === "string" && /^text:\s*/i.test(value); +} + +function repairConversationEvents(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value + .filter((event) => { + if (!isRecord(event)) return true; + return !(event.kind === "step" && isLegacyTextStep(event.text)); + }) + .map((event) => { + if (!isRecord(event) || typeof event.text !== "string") return event; + return { ...event, text: event.text.replace(/<\s+(?=[A-Za-z])/g, "<") }; + }); +} + +function repairProjectionEntry(entry: UnknownRecord): UnknownRecord { + const filterEvents = (value: unknown, labelField: "title" | "label") => + Array.isArray(value) + ? value.filter((event) => !isRecord(event) || !isLegacyTextStep(event[labelField])) + : value; + + return { + ...entry, + latestActivity: isLegacyTextStep(entry.latestActivity) + ? "Subagent update" + : entry.latestActivity, + progressEvents: filterEvents(entry.progressEvents, "title"), + timelineEvents: filterEvents(entry.timelineEvents, "label"), + conversationEvents: repairConversationEvents(entry.conversationEvents), + rawConversationEvents: repairConversationEvents(entry.rawConversationEvents), + }; +} + +function cancelIfIncomplete(entry: UnknownRecord, cancelledAt: number): UnknownRecord { + const repaired = repairProjectionEntry(entry); + const status = typeof repaired.status === "string" ? repaired.status.toLowerCase() : ""; + if (status !== "pending" && status !== "running" && status !== "orphaned") { + return repaired; + } + + const startedAt = typeof repaired.startedAt === "number" ? repaired.startedAt : undefined; + return { + ...repaired, + status: "cancelled", + latestActivity: "Cancelled", + endedAt: typeof repaired.endedAt === "number" ? repaired.endedAt : cancelledAt, + durationMs: + typeof repaired.durationMs === "number" + ? repaired.durationMs + : typeof startedAt === "number" + ? Math.max(0, cancelledAt - startedAt) + : undefined, + }; +} + +/** Repair legacy artifacts and terminalize subagents that belonged to a dead host. */ +export function recoverSubagentProjectionAfterRestart( + projection: T, + cancelledAt = Date.now(), +): T { + const summariesByParentMessageId = Object.fromEntries( + Object.entries(projection.summariesByParentMessageId).map(([parentMessageId, entries]) => [ + parentMessageId, + Array.isArray(entries) + ? entries.map((entry) => isRecord(entry) ? cancelIfIncomplete(entry, cancelledAt) : entry) + : entries, + ]), + ); + const detailsById = Object.fromEntries( + Object.entries(projection.detailsById).map(([id, entry]) => [ + id, + isRecord(entry) ? cancelIfIncomplete(entry, cancelledAt) : entry, + ]), + ); + return { ...projection, summariesByParentMessageId, detailsById } as T; +} diff --git a/src/utils/InspectorNetworkCompatibility.ts b/src/utils/InspectorNetworkCompatibility.ts new file mode 100644 index 0000000..9af6573 --- /dev/null +++ b/src/utils/InspectorNetworkCompatibility.ts @@ -0,0 +1,73 @@ +import * as inspector from "node:inspector"; + +type NetworkDataReceivedPayload = { + /** + * The affected Node builds expose this as a listener for the incoming + * response event, rather than the public protocol-event emitter. Those + * listeners require the response chunk in `data`. + */ + data?: unknown; + dataLength?: unknown; + encodedDataLength?: unknown; + [key: string]: unknown; +}; + +type NetworkDataReceived = (params: NetworkDataReceivedPayload) => void; + +type InspectorNetwork = { + dataReceived?: NetworkDataReceived; +}; + +let installed = false; + +/** + * Shields the extension host from a Node inspector compatibility bug. + * + * Some VS Code/Electron Node builds enable experimental network inspection but + * emit malformed HTTP `Network.dataReceived` events. Depending on the exact + * embedded Node version, the event can omit either the response chunk (`data`) + * or protocol byte counts. Node then throws from its inspector bridge while + * merely observing a response. This is unrelated to the response itself and + * can flood the Extension Host log during ordinary background requests. + */ +export function installInspectorNetworkCompatibility(): void { + if (installed) { + return; + } + installed = true; + + // The Node type declarations do not expose the experimental Network domain, + // even though it is available at runtime in the affected extension hosts. + const network = (inspector as unknown as { Network: InspectorNetwork }).Network; + if (!network || typeof network.dataReceived !== "function") { + return; + } + const originalDataReceived = network.dataReceived; + + network.dataReceived = (params: NetworkDataReceivedPayload) => { + // In the affected VS Code/Electron Node builds this function is an + // internal response-data listener. Calling it without a chunk causes the + // runtime's `originalDataReceived` to throw "Missing data in event". + // There is no valid inspector event to reconstruct without that chunk, so + // discard only the malformed diagnostic event and leave the HTTP response + // itself untouched. + // Checking only for the key is insufficient: the affected runtime can + // produce `{ data: undefined }`, which still reaches its internal + // listener and throws "Missing data in event". Empty chunks remain valid, + // so reject only absent or nullish data. + if (!params || params.data === undefined || params.data === null) { + return; + } + + const normalized = { ...params }; + + if (typeof normalized.dataLength !== "number") { + normalized.dataLength = 0; + } + if (typeof normalized.encodedDataLength !== "number") { + normalized.encodedDataLength = normalized.dataLength; + } + + originalDataReceived(normalized); + }; +} diff --git a/tests/integration/subagent-tracker.test.ts b/tests/integration/subagent-tracker.test.ts index 1d2a668..34a8a00 100644 --- a/tests/integration/subagent-tracker.test.ts +++ b/tests/integration/subagent-tracker.test.ts @@ -389,5 +389,45 @@ describe("SubagentTracker", () => { 1, ); }); + + it("uses the model named by a not-found error instead of the parent-model fallback", () => { + const trackerWithParentFallback = new SubagentTracker(() => ({ + providerID: "zai-coding-plan", + modelID: "glm-5.2", + })); + trackerWithParentFallback.setActiveSession("parent-session-1"); + trackerWithParentFallback.consumeStreamEvent( + makeEvent("message.part.updated", { + part: makeSubtaskPart("parent-session-1", "msg-1", "part-1"), + }), + ); + trackerWithParentFallback.consumeStreamEvent( + makeEvent("session.created", { + info: { id: "child-1", parentID: "parent-session-1" }, + }), + ); + + const beforeError = Object.values( + trackerWithParentFallback.getSnapshotPayload().detailsById, + ).find((detail) => detail.childSessionId === "child-1"); + assert.equal(beforeError?.providerID, "zai-coding-plan"); + assert.equal(beforeError?.modelID, "glm-5.2"); + + const payload = trackerWithParentFallback.consumeStreamEvent( + makeEvent("session.error", { + sessionID: "child-1", + error: { + message: + "Model not found: opencode/gpt-5-nano. Did you mean: gpt-5-nano?", + }, + }), + ); + const erroredDetail = Object.values(payload!.detailsById).find( + (detail) => detail.childSessionId === "child-1", + ); + + assert.equal(erroredDetail?.providerID, "opencode"); + assert.equal(erroredDetail?.modelID, "gpt-5-nano"); + }); }); }); diff --git a/tests/providers/chat-send-pipeline.test.mjs b/tests/providers/chat-send-pipeline.test.mjs index 31cee3a..4851fa3 100644 --- a/tests/providers/chat-send-pipeline.test.mjs +++ b/tests/providers/chat-send-pipeline.test.mjs @@ -121,6 +121,17 @@ test('handleStopRequest aborts the SDK session and cleans up processing state', assert.match(source, /processingSessionIds|clear|cleanup|delete/, 'should clean up processing state'); }); +test('handleStopRequest finalizes the UI before the best-effort SDK abort completes', () => { + const body = extractFunctionBody(source, ' private async handleStopRequest('); + const localFinalization = body.indexOf('this.processingSessionIds.delete(resolvedSessionId);'); + const abortRequest = body.indexOf('client.session.abort({'); + + assert.ok(localFinalization >= 0, 'stop should clear local processing state immediately'); + assert.ok(abortRequest > localFinalization, 'local finalization must not wait for the abort request'); + assert.match(body, /type: "stopRequestHandled"/, 'stop should always notify the webview to end loading'); + assert.match(body, /Failed to abort active request/, 'abort failures should remain diagnostic after UI finalization'); +}); + test('handleLoadSession does not borrow AI processing markers for session loading', () => { const body = extractFunctionBody(source, ' private async handleLoadSession('); diff --git a/tests/providers/stream-event-orchestration.test.mjs b/tests/providers/stream-event-orchestration.test.mjs index cab29ba..bac09a1 100644 --- a/tests/providers/stream-event-orchestration.test.mjs +++ b/tests/providers/stream-event-orchestration.test.mjs @@ -148,9 +148,9 @@ test.skip('todo_update events are batched before posting to the webview', () => test('enriched stream events are forwarded to the webview', () => { assert.match( - streamSubscribeBody, - /type: "streamEvent"[\s\S]*sessionId: resolvedSessionId/s, - 'streamEvent forwarding should include the resolved session id', + chatViewProviderSource, + /type: "streamEvent"[\s\S]*sessionId: item\.sessionId[\s\S]*type: "streamEventBatch"[\s\S]*sessionId: item\.sessionId/s, + 'stream forwarding should preserve the resolved session id for single and batched events', ); assert.match( streamSubscribeBody, diff --git a/tests/regression/stop-button-interrupted-badge-regression.test.mjs b/tests/regression/stop-button-interrupted-badge-regression.test.mjs index c535f5e..2e84325 100644 --- a/tests/regression/stop-button-interrupted-badge-regression.test.mjs +++ b/tests/regression/stop-button-interrupted-badge-regression.test.mjs @@ -31,6 +31,29 @@ test("stopRequestHandled finalizes aborted assistant turns without stale interac ); }); +test("late stream events cannot restart a turn after the user stops it", () => { + assert.match( + messageHandlerSource, + /const isStoppedSession =[\s\S]*?stoppedSessionIds\.has\(sessionId\)/s, + "the handler should keep a session-scoped stop fence", + ); + assert.match( + messageHandlerSource, + /case "streamEvent": \{[\s\S]*?isStoppedSession\(eventSessionId, activeSessionId\)[\s\S]*?break;/s, + "single late stream events should be ignored after stop", + ); + assert.match( + messageHandlerSource, + /case "streamEventBatch": \{[\s\S]*?isStoppedSession\(eventSessionId, activeSessionId\)[\s\S]*?continue;/s, + "batched late stream events should be ignored after stop", + ); + assert.match( + messageHandlerSource, + /case "userMessageAppended":[\s\S]*?stoppedSessionIds\.delete\(resumedSessionId\)/s, + "only a newly appended user message should allow the session to stream again", + ); +}); + test("assistant renderer still shows the Interrupted badge for aborted turns", () => { assert.match( messageComponentsSource, diff --git a/tests/regression/subagent-parent-response-raw-events.test.mjs b/tests/regression/subagent-parent-response-raw-events.test.mjs new file mode 100644 index 0000000..2c3dae1 --- /dev/null +++ b/tests/regression/subagent-parent-response-raw-events.test.mjs @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const tracker = readSource( + [joinFromRoot("src", "services", "SubagentTracker.ts")], + "SubagentTracker.ts", +); +const sessionService = readSource( + [joinFromRoot("src", "services", "SessionService.ts")], + "SessionService.ts", +); +const extractor = readSource( + [ + joinFromRoot( + "webview", + "shared", + "src", + "chat", + "lib", + "subagents", + "centralExtractor.ts", + ), + ], + "centralExtractor.ts", +); +const shell = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); + +test("subagent details preserve a bounded raw event tape", () => { + assert.match(tracker, /rawEvents:\s*unknown\[\]/); + assert.match(tracker, /appendRawEvent\(detail, event\)/); + assert.match(sessionService, /compact\.rawEvents/); +}); + +test("centralized subagents stay attached to their assistant response block", () => { + assert.match( + extractor, + /resolvedParentMessageId = findUltimateParentMessageId/, + ); + assert.match( + extractor, + /resolvedParentMessageId !== parentMessageId/, + ); + assert.match( + shell, + /extractSubagentsFromCentralizedEvents\(messageEvents, messageId\)/, + ); +}); diff --git a/tests/regression/subagent-restart-cancellation.test.mjs b/tests/regression/subagent-restart-cancellation.test.mjs new file mode 100644 index 0000000..18f72cb --- /dev/null +++ b/tests/regression/subagent-restart-cancellation.test.mjs @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const sessionService = readSource( + [joinFromRoot("src", "services", "SessionService.ts")], + "SessionService.ts", +); +const recovery = readSource( + [joinFromRoot("src", "services", "subagents", "SubagentProjectionRecovery.ts")], + "SubagentProjectionRecovery.ts", +); +const provider = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); +const card = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + +test("persisted live subagents become cancelled during extension-host recovery", () => { + assert.match(sessionService, /cancelStaleSubagentsAfterExtensionRestart/); + assert.match(sessionService, /recoverSubagentProjectionAfterRestart/); + assert.match(recovery, /status !== "pending" && status !== "running" && status !== "orphaned"/); + assert.match(recovery, /status: "cancelled"/); + assert.match(provider, /cancelStaleSubagentsAfterExtensionRestart\(sessionId\)/); +}); + +test("cancelled is terminal in the inline subagent card", () => { + assert.match(card, /detailStatus === "cancelled"/); + assert.match(card, /subagent\.status === "cancelled"/); +}); diff --git a/tests/regression/subagent-text-delta-progress-regression.test.mjs b/tests/regression/subagent-text-delta-progress-regression.test.mjs new file mode 100644 index 0000000..9017409 --- /dev/null +++ b/tests/regression/subagent-text-delta-progress-regression.test.mjs @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const trackerSource = readSource( + [joinFromRoot("src", "services", "SubagentTracker.ts")], + "SubagentTracker.ts", +); + +test("text deltas are never synthesized as subagent progress steps", () => { + const start = trackerSource.indexOf("private extractProgressFromPart("); + const end = trackerSource.indexOf("private bindChildSessionToKnownSubtask(", start); + const extractProgressBody = trackerSource.slice(start, end); + + assert.doesNotMatch(extractProgressBody, /title:\s*`\$\{partType\}: \$\{deltaLabel\}`/); + assert.match(extractProgressBody, /Only explicit progress-bearing part/); +}); + +test("streamed markup chunks do not gain a space after an opening angle bracket", () => { + assert.match(trackerSource, /!\/\[\(\[\{<\]\$\//); +}); diff --git a/tests/unit/activity-timeline-collapse.test.mjs b/tests/unit/activity-timeline-collapse.test.mjs index 9c40506..f7974f8 100644 --- a/tests/unit/activity-timeline-collapse.test.mjs +++ b/tests/unit/activity-timeline-collapse.test.mjs @@ -68,6 +68,32 @@ test("lifecycle-only timeline events do not enable an empty expand control", () ); }); +test("lifecycle hide check keys off partType so label-text drift cannot re-enable the empty expand control", () => { + assert.match( + messageComponentsSource, + /const isHiddenLifecycleTimelineEvent = \(event: DisplayEvent\) => \{[\s\S]*partTypeLower[\s\S]*partTypeLower === "step-start" \|\| partTypeLower === "step-finish"/, + "the hide check must treat step-start / step-finish partType values as lifecycle markers regardless of label text", + ); + assert.match( + messageComponentsSource, + /const isHiddenLifecycleTimelineEvent = \(event: DisplayEvent\) => \{[\s\S]*if \(partTypeLower === "step-start" \|\| partTypeLower === "step-finish"\) \{[\s\S]*return true;/, + "a step lifecycle partType should short-circuit the hide check to true without relying on label formatting", + ); +}); + +test("render-loop lifecycle marker detection keys off partType to keep hiding consistent with the count check", () => { + assert.match( + messageComponentsSource, + /const isLifecycleMarkerEvent =[\s\S]*\(event\.partType \|\| ""\)\.trim\(\)\.toLowerCase\(\) === "step-start"[\s\S]*\(event\.partType \|\| ""\)\.trim\(\)\.toLowerCase\(\) === "step-finish"/, + "the render-loop lifecycle marker check must prefer the canonical partType signal so visible-row hiding stays in sync with the expand/collapse count check", + ); + assert.match( + messageComponentsSource, + /if \(isLifecycleMarkerEvent\) \{[\s\S]*const partTypeLower = \(event\.partType \|\| ""\)\.trim\(\)\.toLowerCase\(\);[\s\S]*const isStart = partTypeLower === "step-start"/, + "the start-vs-finish determination inside the lifecycle branch must derive from partType so step-start rows are correctly classified even when their label text is the human-readable 'Starting step' form", + ); +}); + test("collapsed activity timeline renders the worked-for summary affordance", () => { assert.match( messageComponentsSource, diff --git a/tests/unit/inspector-network-compatibility.test.mjs b/tests/unit/inspector-network-compatibility.test.mjs new file mode 100644 index 0000000..a12c812 --- /dev/null +++ b/tests/unit/inspector-network-compatibility.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import path from "node:path"; + +const sourcePath = path.join( + process.cwd(), + "src", + "utils", + "InspectorNetworkCompatibility.ts", +); +const extensionPath = path.join(process.cwd(), "src", "extension.ts"); + +test("inspector network compatibility ignores malformed events and normalizes protocol lengths", () => { + const source = fs.readFileSync(sourcePath, "utf8"); + + assert.match(source, /Network\.dataReceived/); + assert.match(source, /!params \|\| params\.data === undefined \|\| params\.data === null/); + assert.match(source, /Empty chunks remain valid/i); + assert.match(source, /discard only the malformed diagnostic event/i); + assert.match(source, /typeof normalized\.dataLength !== "number"/); + assert.match(source, /normalized\.dataLength = 0/); + assert.match(source, /typeof normalized\.encodedDataLength !== "number"/); + assert.match(source, /normalized\.encodedDataLength = normalized\.dataLength/); +}); + +test("compatibility shim is installed before extension services initialize", () => { + const source = fs.readFileSync(extensionPath, "utf8"); + const shimIndex = source.indexOf("installInspectorNetworkCompatibility();"); + const serverIndex = source.indexOf("serverManager = new OpencodeServerManager(context);"); + + assert.ok(shimIndex >= 0, "activation installs the inspector compatibility shim"); + assert.ok(serverIndex >= 0, "activation initializes the server manager"); + assert.ok(shimIndex < serverIndex, "shim runs before extension services can make requests"); +}); diff --git a/tests/webview/activity-timeline-finished-response-cancellation.test.mjs b/tests/webview/activity-timeline-finished-response-cancellation.test.mjs new file mode 100644 index 0000000..2f32639 --- /dev/null +++ b/tests/webview/activity-timeline-finished-response-cancellation.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const readSource = (path) => readFileSync(new URL(path, import.meta.url), "utf8"); +const messageComponents = readSource("../../webview/shared/src/chat/MessageComponents.tsx"); +const modal = readSource("../../webview/shared/src/chat/SubagentDetailModal.tsx"); + +test("finished assistant responses cancel only their own lingering activity rows", () => { + assert.match( + messageComponents, + /function finishedAssistantResponseMessageIds\(payloads: unknown\[\]\): Set/, + ); + assert.match( + messageComponents, + /finishedResponseMessageIds\.has\(id\)/, + "completion must be joined to the rendered response through its message ID", + ); + assert.match( + messageComponents, + /parentResponseFinished && \(event\.status === "pending" \|\| event\.status === "running"\)[\s\S]*\? "cancelled"/, + "stale activity rows must stop presenting as running after their parent response finishes", + ); +}); + +test("subagent cards and detail modal inherit parent-response cancellation", () => { + assert.match(messageComponents, /resolveSubagentStatus\(subagent, detail, parentResponseFinished\)/); + assert.match(messageComponents, /parentResponseFinished=\{isParentResponseFinished\}/); + assert.match(modal, /parentResponseFinished && \(status === "running" \|\| status === "pending"\)/); + assert.match(modal, /return "cancelled"/); +}); diff --git a/tests/webview/activity-timeline-redundant-content-regression.test.mjs b/tests/webview/activity-timeline-redundant-content-regression.test.mjs index a81857a..9f4cf81 100644 --- a/tests/webview/activity-timeline-redundant-content-regression.test.mjs +++ b/tests/webview/activity-timeline-redundant-content-regression.test.mjs @@ -82,6 +82,24 @@ test("activity timeline also suppresses redundant descriptions and details", () ); }); +test("read activities are header-only and do not reserve an empty payload row", () => { + assert.match( + messageComponentsSource, + /const isReadActivity = labelLower === "read";/, + "read events should be identified before the activity body is rendered", + ); + assert.match( + messageComponentsSource, + /const shouldRenderActivityBody = !isReadActivity;/, + "read events should not mount an otherwise-empty body container", + ); + assert.match( + messageComponentsSource, + /shouldRenderActivityBody \? \(\s*
/s, + "the activity body should render only when it has a visible purpose", + ); +}); + test("question prelude activity rows also suppress redundant summary text", () => { assert.match( messageComponentsSource, diff --git a/tests/webview/centralized-debug-panel-performance.test.mjs b/tests/webview/centralized-debug-panel-performance.test.mjs new file mode 100644 index 0000000..2ce8889 --- /dev/null +++ b/tests/webview/centralized-debug-panel-performance.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const configSource = readSource( + [joinFromRoot("webview", "shared", "src", "config.ts")], + "config.ts", +); +const messageSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + +test("centralized debug tape rendering is disabled by default", () => { + assert.match(configSource, /showCentralizedDebug:\s*false/); +}); + +test("disabled debug tape avoids subscribing to streaming state", () => { + assert.match( + messageSource, + /export const CentralizedDebugPanel = memo\(function CentralizedDebugPanel\(\) \{\s*if \(!config\.debug\.showCentralizedDebug\) \{\s*return null;\s*\}\s*return ;/s, + ); +}); diff --git a/tests/webview/centralized-render-source-of-truth-regression.test.mjs b/tests/webview/centralized-render-source-of-truth-regression.test.mjs index 4b6e5a7..ce45651 100644 --- a/tests/webview/centralized-render-source-of-truth-regression.test.mjs +++ b/tests/webview/centralized-render-source-of-truth-regression.test.mjs @@ -7,6 +7,10 @@ const chatShellSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], "ChatShell.tsx", ); +const transcriptClassificationSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "transcriptMessageClassification.ts")], + "transcriptMessageClassification.ts", +); test("centralized transcript renderer does not fall back to local message state when the tape is empty", () => { const body = extractFunctionBody( @@ -190,13 +194,13 @@ test("centralized builder classifies system messages as full-width entries inste assert.match( body, - /looksLikeStandaloneSystemMessage\(descriptor\.text\)/, + /isExplicitSystemTransportText\(descriptor\.text\)/, "routing loop must fall back to content-based system detection for descriptors with no registered role", ); assert.match( - chatShellSource, - /function looksLikeStandaloneSystemMessage\([\s\S]*?\/\^\\\[\[a-z\]/s, + transcriptClassificationSource, + /export function isExplicitSystemTransportText\([\s\S]*?\/\^\\\[\[a-z\]/s, "content-based fallback should detect bracketed system message patterns", ); @@ -205,4 +209,10 @@ test("centralized builder classifies system messages as full-width entries inste /role:\s*"system",[\s\S]*?info:\s*\{[\s\S]*?role:\s*"system"/s, "system descriptors must be merged with role and info.role set to system for full-width rendering", ); + + assert.match( + body, + /const isStandaloneSystemTextPart[\s\S]*?isExplicitSystemTransportText[\s\S]*?const isAssistantOwnedPart\s*=\s*!isStandaloneSystemTextPart/s, + "standalone system text must bypass the assistant-ID fallback before system classification", + ); }); diff --git a/tests/webview/chat-history-performance-regression.test.mjs b/tests/webview/chat-history-performance-regression.test.mjs index 6f7359e..ca9ccd9 100644 --- a/tests/webview/chat-history-performance-regression.test.mjs +++ b/tests/webview/chat-history-performance-regression.test.mjs @@ -7,6 +7,10 @@ const chatShellSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], "ChatShell.tsx", ); +const messageHandlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); test("ChatShell routes large transcript rendering through a memoized transcript component", () => { assert.match( @@ -77,7 +81,43 @@ test("ChatShell virtualizes very large conversation lists", () => { ); assert.match( chatShellSource, - /= VIRTUALIZED_TRANSCRIPT_MIN_ENTRIES[\s\S]*\? scrollRenderViewport[\s\S]*: STATIC_TRANSCRIPT_VIEWPORT/, + "chat shell should avoid invalidating non-virtualized transcripts on every scroll frame", + ); + assert.match( + chatShellSource, + / { + assert.doesNotMatch( + chatShellSource, + /]*streaming=\{state\.streaming\}/s, + "the memoized transcript should not receive the mutable streaming object", + ); + assert.match( + chatShellSource, + /]*streamingAgent=\{(?:deferredStreamingAgent|state\.streaming\?\.agent)\}[^>]*isStreamingActive=\{Boolean\(state\.streaming\?\.isActive\)\}/s, + "the transcript should receive only stable streaming scalars it actually consumes", + ); + assert.match( + chatShellSource, + /if \(!streamViewport\.isFollowing && state\.streaming\?\.isActive\) \{[\s\S]*return;/, + "centralized transcript projection should pause while the user scrolls through an active stream", + ); + assert.match( + chatShellSource, + /const streamingPresentationRef = useRef\(state\.streaming\);[\s\S]*const presentedStreaming = streamViewport\.isFollowing[\s\S]*streaming=\{presentedStreaming\}/, + "the off-screen streaming card should remain on a stable frame while the user scrolls", + ); +}); + +test("stream events do not echo raw payloads back across the webview IPC boundary", () => { + assert.doesNotMatch( + messageHandlerSource, + /vscode\.postMessage\(\{\s*type: ["']persistRawSdkEventPayload["']/, + "the extension host already persists stream events before forwarding them to the webview", ); }); diff --git a/tests/webview/chat-streaming-readability.test.mjs b/tests/webview/chat-streaming-readability.test.mjs index 7956bf9..192e915 100644 --- a/tests/webview/chat-streaming-readability.test.mjs +++ b/tests/webview/chat-streaming-readability.test.mjs @@ -92,6 +92,21 @@ test('ChatShell implements smart auto-follow pause and jump-to-latest control', /root\.scrollTop\s*=\s*root\.scrollHeight/, 'chat shell should force-scroll to the latest edge when follow-mode is enabled', ); + assert.match( + chatShellSource, + /const onScroll = \(\) => \{[\s\S]*?distanceFromBottom > AUTO_FOLLOW_THRESHOLD_PX[\s\S]*?pauseFollow\("scroll"\)/s, + 'chat shell should synchronously pause follow mode on user scroll so streaming updates cannot fight the gesture', + ); + assert.match( + chatShellSource, + /const onWheel = \(event: WheelEvent\) => \{[\s\S]*?pauseFollow\("wheel"\)/s, + 'chat shell should pause follow mode on wheel intent before a small scroll can be overwritten', + ); + assert.match( + chatShellSource, + /manualScrollIntentUntil = Date\.now\(\) \+ 180/, + 'chat shell should keep manual scroll intent long enough for the scroll frame to settle', + ); }); test('AssistantMessage live streaming card does not clamp its height', () => { diff --git a/tests/webview/live-stream-response-rendering.test.mjs b/tests/webview/live-stream-response-rendering.test.mjs new file mode 100644 index 0000000..1143941 --- /dev/null +++ b/tests/webview/live-stream-response-rendering.test.mjs @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const streamingSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "StreamingComponents.tsx")], + "StreamingComponents.tsx", +); +const messageSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); +const shellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); + +test("renderable stream text paints immediately and centralized transcript takes over after completion", () => { + assert.match( + messageSource, + /!cardMessage[\s\S]*streaming\?\.hasRenderableContent === true[\s\S]*return \[streaming\.content\]/, + "the live response card should render only explicitly safe streamed text", + ); + assert.match( + streamingSource, + /hasTranscriptAssistantForCurrentTurn && !streaming\.isActive/, + "an assistant transcript placeholder must not hide the active live stream", + ); + assert.match( + shellSource, + /!hasRenderableStreamingContent[\s\S]*!isAiResponseBlockFinished/, + "the loading bubble should end when renderable streamed text is available", + ); +}); diff --git a/tests/webview/session-status-rendering.test.mjs b/tests/webview/session-status-rendering.test.mjs index a3558ad..3d81a24 100644 --- a/tests/webview/session-status-rendering.test.mjs +++ b/tests/webview/session-status-rendering.test.mjs @@ -23,6 +23,11 @@ const messageHandlerSource = readSource( 'messageHandler.ts', ); +const chatShellSource = readSource( + [joinFromRoot('webview', 'shared', 'src', 'chat', 'ChatShell.tsx')], + 'ChatShell.tsx', +); + const liveEventRouterSource = readSource( [joinFromRoot('webview', 'shared', 'src', 'chat', 'lib', 'liveEventRouter.ts')], 'liveEventRouter.ts', @@ -128,6 +133,22 @@ describe('Live-only event parsing and leak prevention', () => { ); }); + test('client-only live event batches route tui.show notifications into the toast overlay', () => { + assert.match( + messageHandlerSource, + /case "liveEventStreamDebugBatch": \{[\s\S]*?routeLiveEventToUi\(event, sessionId, "liveEventStreamDebugBatch", eventIndex\)/s, + 'startup tui.show events that only reach the client-only live stream should still be rendered as toasts', + ); + }); + + test('chat shell subscribes to the per-session live toast state it passes to the overlay', () => { + assert.match( + chatShellSource, + /liveToastNotificationsBySessionId: appState\.liveToastNotificationsBySessionId/, + 'the overlay must receive live toast changes through the ChatContent state selector', + ); + }); + test('liveEventRouter is the canonical discoverable routing table for live-only events', () => { assert.match( liveEventRouterSource, @@ -156,8 +177,8 @@ describe('Live-only event parsing and leak prevention', () => { ); assert.match( messageHandlerSource, - /const liveRoute = routeLiveEvent\(payload\)/, - 'streamEvent should use the router for live-event dispatch', + /const routeLiveEventToUi = \([\s\S]*?routeLiveEvent\(payload, index\)/s, + 'stream events should use the centralized router for live-event dispatch', ); }); }); diff --git a/tests/webview/stream-event-main-thread-performance.test.mjs b/tests/webview/stream-event-main-thread-performance.test.mjs new file mode 100644 index 0000000..5f6a724 --- /dev/null +++ b/tests/webview/stream-event-main-thread-performance.test.mjs @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const storeSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "store.ts")], + "store.ts", +); +const handlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); +const providerSource = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); +const streamHandlerSource = readSource( + [joinFromRoot("src", "providers", "chat", "StreamEventHandler.ts")], + "StreamEventHandler.ts", +); + +test("reasoning deltas bypass full accumulated-buffer normalization", () => { + assert.match( + storeSource, + /if \(delta\) \{\s*return \{\s*reasoning: appendStreamingReasoning\(current, incoming\)/s, + ); + assert.match( + storeSource, + /mergeStreamingReasoning\(\s*state\.streaming\.reasoning,[\s\S]*?action\.payload\.delta/s, + ); +}); + +test("live duplicate-token checks use a bounded response suffix", () => { + assert.match( + handlerSource, + /comparableTokens\(candidateContent\.slice\(-2048\)\)/, + ); + assert.doesNotMatch( + handlerSource, + /const candidateTokens = comparableTokens\(candidateContent\);/, + ); +}); + +test("extension-host stream delivery is capped below token event frequency", () => { + assert.match( + providerSource, + /STREAM_WEBVIEW_FLUSH_INTERVAL_MS = 50/, + ); + assert.match( + streamHandlerSource, + /STREAM_WEBVIEW_FLUSH_INTERVAL_MS = 50/, + ); + assert.match( + streamHandlerSource, + /MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH = 8/, + ); + assert.match( + streamHandlerSource, + /pendingEvents\.splice\(0, MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH\)/, + ); + assert.match( + providerSource, + /MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH = 8/, + ); + assert.match( + providerSource, + /pendingStreamWebviewEvents\.splice\(\s*0,\s*ChatViewProvider\.MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH/s, + ); + assert.match( + providerSource, + /STREAM_WEBVIEW_BACKLOG_YIELD_MS = 16/, + ); + assert.doesNotMatch( + providerSource, + /flushStreamWebviewEvents\(true\)/, + "immediate lifecycle events must not bypass the stream batch cap", + ); +}); + +test("webview appends a streamed raw-event batch with one tape reducer update", () => { + assert.match( + handlerSource, + /const rawEventsBySessionId = new Map\(\);/, + ); + assert.match( + handlerSource, + /type: "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH"/, + ); + assert.match( + storeSource, + /case "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH":/, + ); +}); + +test("host only mirrors live-only events into the client debug stream", () => { + assert.match( + providerSource, + /eventType === "tui\.show"[\s\S]*?eventType === "tui\.toast\.show"[\s\S]*?eventType === "session\.status"[\s\S]*?enqueueLiveEventDebugEvent\(/, + ); +}); + +test("webview transport bounds oversized tool output without truncating persisted events", () => { + assert.match( + providerSource, + /MAX_STREAM_WEBVIEW_TOOL_OUTPUT_CHARS = 16_384/, + ); + assert.match( + providerSource, + /private buildWebviewStreamEvent\(/, + ); + assert.match( + providerSource, + /const eventForWebview = this\.buildWebviewStreamEvent\(/, + ); + assert.match( + providerSource, + /const centralizedEventPayload = \{\s*\.\.\.enrichedEvent,/s, + ); +}); diff --git a/tests/webview/stream-performance-debug.test.mjs b/tests/webview/stream-performance-debug.test.mjs new file mode 100644 index 0000000..a1f22c2 --- /dev/null +++ b/tests/webview/stream-performance-debug.test.mjs @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const providerSource = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); +const streamHandlerSource = readSource( + [joinFromRoot("src", "providers", "chat", "StreamEventHandler.ts")], + "StreamEventHandler.ts", +); +const loggerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "logger.ts")], + "logger.ts", +); +const messageHandlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); + +test("stream-performance diagnostics are always available during investigation", () => { + assert.doesNotMatch(providerSource, /isStreamPerformanceDebugEnabled/); + assert.doesNotMatch(messageHandlerSource, /setStreamPerformanceDebug/); + assert.doesNotMatch(loggerSource, /streamPerformanceDebug/); +}); + +test("performance logs are sampled and cover host, event batch, and scroll phases", () => { + assert.match(loggerSource, /now - previous < 250/); + assert.match(messageHandlerSource, /streamPerformance\("stream-event-batch"/); + assert.match(streamHandlerSource, /\[STREAM-PERF\] host-stream-flush/); + assert.match(providerSource, /logStreamPerformance\("provider-webview-flush"/); + assert.match( + readSource([joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], "ChatShell.tsx"), + /streamPerformance\("scroll-input"/, + ); +}); diff --git a/tests/webview/subagent-initiator-transcript-filter.test.mjs b/tests/webview/subagent-initiator-transcript-filter.test.mjs new file mode 100644 index 0000000..99e8d04 --- /dev/null +++ b/tests/webview/subagent-initiator-transcript-filter.test.mjs @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { extractFunctionBody, joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const ownershipSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "backgroundTaskOwnership.ts")], + "backgroundTaskOwnership.ts", +); +const chatShellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); +const classificationSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "transcriptMessageClassification.ts")], + "transcriptMessageClassification.ts", +); + +test("subagent initiator ownership resolves a part through its parent message", () => { + const body = extractFunctionBody( + ownershipSource, + "export function isSubagentInitiatorMessage(params:", + ); + + assert.match(body, /message\.info\?\.id, message\.id, message\.messageId/); + assert.match(body, //); + assert.match(body, /getCentralizedEventType\(payload\) !== "message\.updated"/); + assert.match(body, /eventMessageId === messageId && eventRole === "user" && !!agent/); + assert.doesNotMatch(body, /startsWith\(["']prt_/i, "part ids are not subagent ownership ids"); +}); + +test("subagent initiator user messages are hidden before transcript bubble rendering", () => { + const body = extractFunctionBody( + classificationSource, + "export function classifyCentralizedTranscriptMessage(params:", + ); + + assert.match( + body, + /isSubagentInitiatorMessage\(\{[\s\S]*message,[\s\S]*rawSdkEventPayloads,[\s\S]*\}\)[\s\S]*return "hidden"/, + ); +}); + +test("child-session events are hidden from the parent transcript before bubble rendering", () => { + const ownershipBody = extractFunctionBody( + ownershipSource, + "export function isCrossSessionSubagentMessage(params:", + ); + const classificationBody = extractFunctionBody( + classificationSource, + "export function classifyCentralizedTranscriptMessage(params:", + ); + + assert.match( + ownershipBody, + /part\?\.sessionID,[\s\S]*?info\?\.sessionID,[\s\S]*?properties\?\.sessionID/, + "the ownership check must compare the event's explicit scoped session to its envelope", + ); + assert.match( + ownershipBody, + /envelopeSessionId !== scopedSessionId/, + "only cross-session events are excluded", + ); + assert.match( + classificationBody, + /isCrossSessionSubagentMessage\(\{ message, rawSdkEventPayloads \}\)[\s\S]*?return "hidden"/, + "child-session events must not reach main transcript rendering", + ); +}); + +test("server-authored search-mode parts override their transport user role", () => { + const body = extractFunctionBody( + chatShellSource, + "function buildCentralizedRenderMessages(", + ); + + assert.match( + body, + /if \(messageId && isStandaloneSystemTextPart\) \{[\s\S]*systemMessageIds\.add\(messageId\);[\s\S]*messageRolesById\.set\(messageId, "system"\);[\s\S]*userMessageIds\.delete\(messageId\);/, + "an explicit system directive must win over the SDK's transport user role", + ); +}); diff --git a/tests/webview/subagent-inline-card-block-deduplication.test.mjs b/tests/webview/subagent-inline-card-block-deduplication.test.mjs new file mode 100644 index 0000000..d8a9f2e --- /dev/null +++ b/tests/webview/subagent-inline-card-block-deduplication.test.mjs @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const source = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + +test("a contiguous assistant response block renders one shared subagent panel", () => { + assert.match( + source, + /const shouldRenderSubagentsInlineCard = !message \|\| isLastInBlock !== false;/, + "the live card or final visible response card must own the subagent panel", + ); + assert.match( + source, + /\{shouldRenderSubagentsInlineCard && \(\s* { + assert.match( + modalSource, + /oc-modal-content min-h-0(?: [^\"]+)? overflow-y-auto/, + "the modal content region should own vertical scrolling", + ); + + const stepperClass = modalSource.match( + /className="([^"]*oc-refined-stepper[^"]*)"\s*\n\s*autoScrollToBottom=\{false\}/, + )?.[1]; + + assert.ok(stepperClass, "the conversation stepper should remain present"); + assert.doesNotMatch( + stepperClass, + /(?:overflow-y-auto|max-h-\[)/, + "the conversation stepper must not create a nested vertical scroll region", + ); + + assert.doesNotMatch( + modalSource, + /sticky top-0 z-\[1\].*Assistant Conversation/s, + "the conversation label must scroll with its content instead of overlaying timeline rows", + ); + assert.match( + modalSource, + /document\.body\.style\.overflow = "hidden"/, + "opening the modal should prevent the page behind it from becoming a second scroll target", + ); + assert.doesNotMatch(modalSource, /Copy Refs|Jump to Parent|onCopyRefs|onJumpToParent/); +}); + +test("subagent selection is stored outside stream-remounted message cards", () => { + const messageSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", + ); + + assert.match(messageSource, /selectedSubagentId: state\.selectedSubagentId/); + assert.match( + messageSource, + /const openSubagentModal[\s\S]*?SELECT_SUBAGENT", payload: subagentId/s, + ); +}); diff --git a/tests/webview/subagent-raw-hydration-priority.test.mjs b/tests/webview/subagent-raw-hydration-priority.test.mjs new file mode 100644 index 0000000..33fea34 --- /dev/null +++ b/tests/webview/subagent-raw-hydration-priority.test.mjs @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const handlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); +const source = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "subagents", "hydrationSource.ts")], + "hydrationSource.ts", +); + +test("chat-history subagent rendering derives from the raw SDK tape before legacy maps", () => { + assert.match(source, /The raw SDK tape is authoritative/); + assert.match(source, /const fromRaw = extractSubagentsFromCentralizedEvents/); + assert.match(source, /return fromRaw/); + assert.match(handlerSource, /resolveHydratedSubagentProjection/); +}); diff --git a/tests/webview/subagent-status-icon-regression.test.mjs b/tests/webview/subagent-status-icon-regression.test.mjs new file mode 100644 index 0000000..a1f54af --- /dev/null +++ b/tests/webview/subagent-status-icon-regression.test.mjs @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const source = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + +test("only completed subagents use a success checkmark", () => { + const cardStart = source.indexOf('data-assistant-section="subagents-inline-card"'); + const cardSource = source.slice(cardStart); + + assert.match(cardSource, /resolvedStatus === "orphaned"[\s\S]*?/i.test(trimmed) || - /^")) { + return false; + } + + return rawSdkEventPayloads.some((payload) => { + if (getCentralizedEventType(payload) !== "message.updated") { + return false; + } + const info = getCentralizedEventInfo(payload); + const eventMessageId = firstNonEmptyString(info?.id, info?.messageID, info?.messageId); + const eventRole = firstNonEmptyString(info?.role)?.toLowerCase(); + const agent = firstNonEmptyString(info?.agent); + return eventMessageId === messageId && eventRole === "user" && !!agent; + }); +} diff --git a/webview/shared/src/chat/lib/logger.ts b/webview/shared/src/chat/lib/logger.ts index fe72150..51e819f 100644 --- a/webview/shared/src/chat/lib/logger.ts +++ b/webview/shared/src/chat/lib/logger.ts @@ -11,8 +11,9 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error'; class WebviewLogger { private logLevel: LogLevel = 'warn'; private sessionId: string | null = null; - private showLogger: boolean = true; - private showBrowserConsoleOverride: boolean | null = null; + private showLogger: boolean = true; + private showBrowserConsoleOverride: boolean | null = null; + private readonly lastPerformanceLogAt = new Map(); setSession(sessionId: string): void { this.sessionId = sessionId; @@ -22,9 +23,39 @@ class WebviewLogger { this.showLogger = enabled; } - setShowBrowserConsole(enabled: boolean): void { - this.showBrowserConsoleOverride = enabled; - } + setShowBrowserConsole(enabled: boolean): void { + this.showBrowserConsoleOverride = enabled; + } + + streamPerformance(metric: string, context: Record = {}): void { + const now = performance.now(); + const previous = this.lastPerformanceLogAt.get(metric) ?? -Infinity; + // Instrumentation must not become a stream-event bottleneck itself. + if (now - previous < 250) { + return; + } + this.lastPerformanceLogAt.set(metric, now); + const payload = { + ...context, + metric, + timestamp: Date.now(), + source: "webview", + sessionId: this.sessionId, + }; + if (this.showBrowserConsoleOverride ?? config.debug.showBrowserConsole) { + console.debug("[WebView][STREAM-PERF]", payload); + } + try { + vscode.postMessage({ + type: "webviewLog", + level: "info", + message: `[STREAM-PERF] ${metric}`, + context: payload, + }); + } catch { + // Best-effort diagnostics only. + } + } private shouldLog(level: LogLevel): boolean { if (!this.showLogger) { diff --git a/webview/shared/src/chat/lib/messageHandler.ts b/webview/shared/src/chat/lib/messageHandler.ts index 1ef9d1c..26d0cf1 100644 --- a/webview/shared/src/chat/lib/messageHandler.ts +++ b/webview/shared/src/chat/lib/messageHandler.ts @@ -59,6 +59,7 @@ import { routeLiveEvent } from "./liveEventRouter"; import { extractSubagentsFromCentralizedEvents as modularExtractSubagentsFromCentralizedEvents } from './subagents/centralExtractor'; +import { resolveHydratedSubagentProjection } from "./subagents/hydrationSource"; import { mergeSubagentSummaries, hasSubagentSummaryEntries, @@ -6027,6 +6028,7 @@ function normalizeHydratedSubagentMaps( messages: Message[], freezeIncompleteStatuses: boolean, policy?: SubagentPresentationPolicy, + cancelIncompleteStatuses = false, ): { summariesByParentMessageId: Record; detailsById: Record; @@ -6087,10 +6089,40 @@ function normalizeHydratedSubagentMaps( }); } - return { + const normalized = { summariesByParentMessageId: normalizedSummariesByParentMessageId, detailsById: normalizedDetailsById, }; + + if (!cancelIncompleteStatuses) { + return normalized; + } + + const isIncomplete = (status: string | undefined) => + status === "pending" || status === "running" || status === "orphaned"; + const detailsAfterRestart = Object.fromEntries( + Object.entries(normalized.detailsById).map(([id, detail]) => [ + id, + isIncomplete(detail.status) + ? { ...detail, status: "cancelled" as const, latestActivity: "Cancelled" } + : detail, + ]), + ); + const summariesAfterRestart = Object.fromEntries( + Object.entries(normalized.summariesByParentMessageId).map(([parentId, summaries]) => [ + parentId, + summaries.map((summary) => { + const detail = detailsAfterRestart[summary.id]; + if (detail) { + return { ...summary, status: detail.status, latestActivity: detail.latestActivity }; + } + return isIncomplete(summary.status) + ? { ...summary, status: "cancelled" as const, latestActivity: "Cancelled" } + : summary; + }), + ]), + ); + return { summariesByParentMessageId: summariesAfterRestart, detailsById: detailsAfterRestart }; } function canCoalesceAssistantHistoryMessages( @@ -8999,7 +9031,10 @@ function handleStreamEvent( const candidateContent = contentPatch.append ? (streamingState?.content || '') + contentPatch.content : contentPatch.content; - const candidateTokens = comparableTokens(candidateContent); + // Duplicate-token protection only needs the recent boundary where + // chunks were joined. Tokenizing the full accumulated response on + // every delta is quadratic for long answers and blocks scrolling. + const candidateTokens = comparableTokens(candidateContent.slice(-2048)); if (hasDuplicateTokenPattern(candidateTokens)) { dispatch({ type: "UPDATE_STREAMING_REASONING", @@ -10518,6 +10553,16 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const stoppedSessionIds = new Set(); let awaitingInteractiveTurnStart = false; + // Stopping is an immediate, user-owned transition. The server can still + // deliver events that were already buffered when the abort was requested; + // those events must not bootstrap another streaming card for the stopped + // turn. A subsequent userMessageAppended event removes the session from this + // set when a genuinely new turn begins. + const isStoppedSession = (...sessionIds: Array) => { + const sessionId = firstNonEmptyString(...sessionIds); + return !!sessionId && stoppedSessionIds.has(sessionId); + }; + const markAssistantTurnClosed = (...messageIds: Array) => { for (const messageId of messageIds) { if (messageId) { @@ -10578,6 +10623,35 @@ export function createMessageHandler(dispatch: Dispatch, getState: () return false; }; + /** Route all ephemeral SDK UI events through one observable path. */ + const routeLiveEventToUi = ( + payload: unknown, + sessionId: string | null, + source: "streamEvent" | "streamEventBatch" | "liveEventStreamDebugBatch", + index = 0, + ) => { + const liveRoute = routeLiveEvent(payload, index); + logger.info("[LIVE-TOAST] route", { + source, + sessionId, + eventType: asString(asRecord(payload)?.type) ?? "unknown", + hasToast: !!liveRoute.toast, + hasSessionStatus: !!liveRoute.sessionStatus, + toastKey: liveRoute.toast?.key ?? null, + }); + + if (liveRoute.toast) { + dispatch({ + type: "APPEND_LIVE_TOAST_NOTIFICATION", + payload: { sessionId, notification: liveRoute.toast }, + }); + } + if (liveRoute.sessionStatus) { + dispatch({ type: "UPDATE_LIVE_SESSION_STATUS", payload: liveRoute.sessionStatus }); + } + return liveRoute; + }; + const isLikelyInteractiveAnswerSubmissionMessage = (message: Message): boolean => { const role = asString(message.role) || asString(asRecord(message.info)?.role); if (role !== "user") { @@ -10642,6 +10716,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () // Set processing state BEFORE handling message types to ensure streaming state is created early. // Never bootstrap "in progress" UI from compaction lifecycle messages. + // Also suppress once the active assistant turn has finished: trailing events + // (session.diff, replayed parent-user messages) don't carry the assistant id, + // so we fall back to the live assistantTurnMessageId latch. + const currentAssistantTurnMessageId = getState().assistantTurnMessageId || null; + const currentAssistantTurnIsClosed = + !!currentAssistantTurnMessageId && + closedAssistantTurnMessageIds.has(currentAssistantTurnMessageId); const shouldSuppressProcessingBootstrap = !!( awaitingInteractiveTurnStart && asBoolean(data.processing, false) && @@ -10652,7 +10733,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () ) || ( !!eventMessageId && closedAssistantTurnMessageIds.has(eventMessageId) - ); + ) || currentAssistantTurnIsClosed; if ( asBoolean(data.processing, false) && type !== "compactionStatus" && @@ -10796,7 +10877,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () logger.setShowLogger(showLogger); dispatch({ type: "SET_SHOW_LOGGER", payload: showLogger }); } - break; } case "modelsList": { @@ -10972,13 +11052,21 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } case "stopRequestHandled": { awaitingInteractiveTurnStart = false; - const stoppedSessionId = firstNonEmptyString( + const handledSessionId = firstNonEmptyString( asString(data.sessionId), asString(data.sessionID), getState().currentSessionId ?? undefined, ); - if (stoppedSessionId) { - stoppedSessionIds.add(stoppedSessionId); + const currentSessionId = getState().currentSessionId; + if ( + handledSessionId && + currentSessionId && + handledSessionId !== currentSessionId + ) { + break; + } + if (handledSessionId) { + stoppedSessionIds.add(handledSessionId); } const currentStreaming = getState().streaming; latestStreamingSnapshot = currentStreaming ?? latestStreamingSnapshot; @@ -11065,46 +11153,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () dispatch({ type: "SET_INTERACTIVE_EVENTS", payload: [] }); break; } - case "deferredPromptAccepted": { - const messageRecord = asRecord(data.message); - const timeRecord = asRecord(messageRecord?.time); - const sessionId = firstNonEmptyString( - asString(data.sessionId), - asString(data.sessionID), - asString(messageRecord?.sessionID), - getState().currentSessionId ?? undefined, - ); - const text = firstNonEmptyString( - asString(messageRecord?.text), - asString(data.text), - ); - if (!sessionId || !text) { - break; - } - - dispatch({ - type: "ADD_PENDING_DEFERRED_PROMPT", - payload: { - id: - firstNonEmptyString( - asString(messageRecord?.id), - asString(data.clientRequestId), - ) ?? `deferred-${Date.now()}`, - sessionId, - createdAt: - asOptionalNumber(timeRecord?.created) ?? - asOptionalNumber(data.createdAt) ?? - Date.now(), - text, - files: asArray(data.files, (item): item is string => typeof item === "string"), - contexts: asArray(data.contexts, (item): item is ContextItem => !!asRecord(item)), - images: Array.isArray(data.images) ? data.images : undefined, - agent: asOptionalString(data.agent), - clientRequestId: asOptionalString(data.clientRequestId), - }, - }); - break; - } case "messageResponse": { awaitingInteractiveTurnStart = false; const msg = @@ -11436,6 +11484,8 @@ export function createMessageHandler(dispatch: Dispatch, getState: () mode: "hydration", sessionProcessing: false, }; + const cancelIncompleteHydratedSubagents = + (data as UnknownRecord).subagentsRecoveredAfterRestart === true; try { terminalErrorReached = false; dispatch({ type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" }); @@ -11588,16 +11638,10 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const postHydrationState = getState(); const rawSdkEventPayloads = postHydrationState.rawSdkEventPayloadsBySessionId?.[chatHistorySessionId] ?? []; - const extractedHydratedSubagents = - persistedSubagents && - (asRecord(persistedSubagents.summariesByParentMessageId) || - asRecord(persistedSubagents.detailsById)) - ? { - summariesByParentMessageId: - persistedSubagents.summariesByParentMessageId || {}, - detailsById: persistedSubagents.detailsById || {}, - } - : extractSubagentsFromCentralizedEvents(rawSdkEventPayloads); + const extractedHydratedSubagents = resolveHydratedSubagentProjection( + rawSdkEventPayloads, + persistedSubagents, + ); const normalizedHydratedSubagents = normalizeHydratedSubagentMaps( extractedHydratedSubagents.summariesByParentMessageId, @@ -11605,6 +11649,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () canonicalMessages, false, hydrationPresentationPolicy, + cancelIncompleteHydratedSubagents, ); if ( Object.keys(normalizedHydratedSubagents.summariesByParentMessageId) @@ -12250,7 +12295,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } case "liveEventStreamDebugBatch": { const events = Array.isArray(data.events) ? data.events : []; - for (const item of events) { + for (const [eventIndex, item] of events.entries()) { const event = asRecord(item.event) ?? item; const sessionId = asString(item.sessionId) || @@ -12265,6 +12310,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () type: "APPEND_LIVE_EVENT_STREAM_DEBUG", payload: { sessionId, event }, }); + + // The host mirrors every SDK event into this client-only batch + // before its processing gate. That includes startup `tui.show` + // notifications, which can otherwise never reach streamEvent. + // Route them here as well so the centralized live tape remains + // the source for visible, ephemeral UI notifications. + routeLiveEventToUi(event, sessionId, "liveEventStreamDebugBatch", eventIndex); } break; } @@ -12282,6 +12334,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () asString(asRecord(payload.properties)?.sessionId) || asString(asRecord(payload.properties)?.sessionID); const activeSessionId = stateBeforeStreamEvent.currentSessionId; + if (isStoppedSession(eventSessionId, activeSessionId)) { + logger.info("[LIVE-EVENT] ignoring late event for stopped session", { + streamEventType, + sessionId: eventSessionId || activeSessionId || null, + }); + break; + } const hasConfirmedProcessingSession = !!( (eventSessionId && stateBeforeStreamEvent.processingSessionIds.includes(eventSessionId)) || @@ -12300,7 +12359,11 @@ export function createMessageHandler(dispatch: Dispatch, getState: () streamEventCanStartVisibleAssistantTurn(payload); const centralizedDisposition = getCentralizedDebugPayloadDisposition(payload); - const liveRoute = routeLiveEvent(payload); + const liveRoute = routeLiveEventToUi( + payload, + eventSessionId || activeSessionId || null, + "streamEvent", + ); const liveToastNotification = liveRoute.toast ?? null; const liveSessionStatus = liveRoute.sessionStatus ?? null; const shouldLogStreamEvent = !streamEventType.includes("message.part") || @@ -12318,36 +12381,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () toastType: liveToastNotification?.type ?? null, }); - if (liveToastNotification) { - logger.info("[LIVE-EVENT] dispatching APPEND_LIVE_TOAST_NOTIFICATION", { - toastKey: liveToastNotification.key, - toastType: liveToastNotification.type, - title: liveToastNotification.title, - message: liveToastNotification.message, - sessionId: eventSessionId || activeSessionId || null, - }); - dispatch({ - type: "APPEND_LIVE_TOAST_NOTIFICATION", - payload: { - sessionId: eventSessionId || activeSessionId || null, - notification: liveToastNotification, - }, - }); - } - - if (liveSessionStatus) { - logger.info("[LIVE-EVENT] dispatching UPDATE_LIVE_SESSION_STATUS", { - statusType: liveSessionStatus.statusType, - attempt: liveSessionStatus.attempt ?? null, - message: liveSessionStatus.message ?? null, - sessionId: eventSessionId || activeSessionId || null, - }); - dispatch({ - type: "UPDATE_LIVE_SESSION_STATUS", - payload: liveSessionStatus, - }); - } - // Persist the raw centralized tape as soon as the stream event is accepted. // We do this before the visibility gate so the debug tape and hydrated // session state stay in sync even while the live UI waits for a renderable @@ -12370,14 +12403,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () event: payload, }, }); - const persistSessionId = eventSessionId || activeSessionId || getState().currentSessionId || null; - if (persistSessionId) { - vscode.postMessage({ - type: "persistRawSdkEventPayload", - sessionId: persistSessionId, - event: payload, - }); - } } else if ( (centralizedDisposition === "excluded-noise" || centralizedDisposition === "live-only") && streamEventType !== "server.heartbeat" && @@ -12519,6 +12544,8 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } case "streamEventBatch": { const events = Array.isArray(data.events) ? data.events : []; + const batchStartedAt = performance.now(); + const rawEventsBySessionId = new Map(); startTransition(() => { for (const [eventIndex, item] of events.entries()) { const evtPayload = asRecord(item.event) ?? item; @@ -12534,31 +12561,27 @@ export function createMessageHandler(dispatch: Dispatch, getState: () asString(asRecord(evtPayload.properties)?.sessionID); const stateBeforeBatchEvent = getState(); const activeSessionId = stateBeforeBatchEvent.currentSessionId; - const disposition = getCentralizedDebugPayloadDisposition(evtPayload); - const batchLiveRoute = routeLiveEvent(evtPayload, eventIndex); - if (batchLiveRoute.toast) { - dispatch({ - type: "APPEND_LIVE_TOAST_NOTIFICATION", - payload: { - sessionId: eventSessionId || activeSessionId || null, - notification: batchLiveRoute.toast, - }, - }); - } - if (batchLiveRoute.sessionStatus) { - dispatch({ - type: "UPDATE_LIVE_SESSION_STATUS", - payload: batchLiveRoute.sessionStatus, + if (isStoppedSession(eventSessionId, activeSessionId)) { + logger.info("[LIVE-EVENT] ignoring late batch event for stopped session", { + streamEventType: evtType, + sessionId: eventSessionId || activeSessionId || null, }); + continue; } + const disposition = getCentralizedDebugPayloadDisposition(evtPayload); + routeLiveEventToUi( + evtPayload, + eventSessionId || activeSessionId || null, + "streamEventBatch", + eventIndex, + ); if (disposition === "persist" && evtType !== "server.heartbeat") { - dispatch({ - type: "APPEND_RAW_SDK_EVENT_PAYLOAD", - payload: { - sessionId: eventSessionId || activeSessionId || null, - event: evtPayload, - }, - }); + const rawSessionId = eventSessionId || activeSessionId || null; + if (rawSessionId) { + const queued = rawEventsBySessionId.get(rawSessionId) ?? []; + queued.push(evtPayload); + rawEventsBySessionId.set(rawSessionId, queued); + } } if ( eventSessionId && @@ -12627,11 +12650,23 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } } } + for (const [sessionId, rawEvents] of rawEventsBySessionId) { + dispatch({ + type: "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH", + payload: { sessionId, events: rawEvents }, + }); + } }); const streamingAfter = getState().streaming; if (streamingAfter) { latestStreamingSnapshot = streamingAfter; } + logger.streamPerformance("stream-event-batch", { + eventCount: events.length, + durationMs: Number((performance.now() - batchStartedAt).toFixed(2)), + streamingContentLength: streamingAfter?.content.length ?? 0, + streamingReasoningLength: streamingAfter?.reasoning.length ?? 0, + }); break; } case "streamEventEnrich": { diff --git a/webview/shared/src/chat/lib/sessionProcessing.test.ts b/webview/shared/src/chat/lib/sessionProcessing.test.ts index 2351d90..63afd0f 100644 --- a/webview/shared/src/chat/lib/sessionProcessing.test.ts +++ b/webview/shared/src/chat/lib/sessionProcessing.test.ts @@ -123,6 +123,59 @@ describe('isAssistantRespondingInCurrentSession', () => { assert.strictEqual(result, false); }); + it('does not re-arm loading when trailing events arrive after the assistant finish signal', () => { + // Regression: trailing events (session.diff, replayed parent-user + // message.updated) after `finish:"stop"` used to re-arm isProcessing in + // messageHandler because they don't carry the assistant messageId. The + // fix latches loading OFF once the active assistant turn is closed; this + // pins the centralized-tape half of that contract. + const finishedTape = [ + { + id: 'evt_1', + type: 'message.updated', + properties: { + info: { + id: 'msg_assistant_1', + role: 'assistant', + finish: 'stop', + }, + }, + }, + { + id: 'evt_2', + type: 'session.diff', + properties: {}, + }, + { + id: 'evt_3', + type: 'message.updated', + properties: { + info: { + id: 'msg_user_1', + role: 'user', + }, + }, + }, + ]; + + assert.strictEqual( + hasActiveAssistantReplyInCentralizedTape(finishedTape), + false, + ); + assert.strictEqual( + isAssistantRespondingInCurrentSession( + false, + 'ses_1', + [], + false, + false, + false, + finishedTape, + ), + false, + ); + }); + it('treats a message-scoped abort marker as a completed assistant reply', () => { const result = hasCompletedAssistantReplyInCentralizedTape([ { diff --git a/webview/shared/src/chat/lib/sessionProcessing.ts b/webview/shared/src/chat/lib/sessionProcessing.ts index a528986..1f56303 100644 --- a/webview/shared/src/chat/lib/sessionProcessing.ts +++ b/webview/shared/src/chat/lib/sessionProcessing.ts @@ -509,3 +509,34 @@ export function shouldDeferComposerSendInCurrentSession( processingSessionIds.includes(currentSessionId) ); } + +/** + * Derives the ID of the "pending" (last incomplete) assistant message from the + * centralized message tape. Mirrors the opencode TUI `pending` memo: a user + * message is considered QUEUED when its ID is greater than this value, meaning + * it was sent after the still-running assistant turn started and the server has + * queued it for processing once the current turn completes. + * + * Returns null when no assistant turn is in flight. + */ +export function computePendingAssistantMessageId( + messages: T[], +): string | null { + let lastCompletedAssistantId: string | null = null; + let pendingId: string | null = null; + + for (const message of messages) { + const role = message.role ?? message.info?.role; + if (role !== "assistant") continue; + const completed = message.info?.time?.completed; + if (completed) { + lastCompletedAssistantId = message.id; + } else { + if (!lastCompletedAssistantId || message.id > lastCompletedAssistantId) { + pendingId = message.id; + } + } + } + + return pendingId; +} diff --git a/webview/shared/src/chat/lib/store.ts b/webview/shared/src/chat/lib/store.ts index 376d0f6..84a6994 100644 --- a/webview/shared/src/chat/lib/store.ts +++ b/webview/shared/src/chat/lib/store.ts @@ -24,6 +24,12 @@ import { shouldIncludeCentralizedDebugPayload, } from "./generated/centralizedDebugPayloadFilter"; import type { CentralizedToastNotification } from "./toastEvents"; +import { + createSubagentEntityStore, + selectSubagentSummariesByParentMessageId, + upsertSubagentDetailsIntoStore, + upsertSubagentSummariesIntoStore, +} from "./subagents/stateManager"; const globalQuestionRequestIDMap = new Map(); @@ -41,7 +47,6 @@ import type { FileResult, Message, Model, - PendingDeferredPrompt, PendingUserMessage, QueueItem, QuotaData, @@ -78,7 +83,6 @@ export const initialState: AppState = { liveToastNotificationsBySessionId: {}, promptQueue: [], queueBySessionId: {}, - pendingDeferredPromptsBySessionId: {}, pendingUserMessagesBySessionId: {}, isExecutingQueue: false, executingQueueSessionIds: new Set(), @@ -137,6 +141,7 @@ export const initialState: AppState = { assistantTurnMessageId: null, modelCapability: null, todoItems: [], + subagentStore: createSubagentEntityStore(), subagentsByParentMessageId: {}, subagentDetailsById: {}, selectedSubagentId: null, @@ -196,6 +201,10 @@ export type AppAction = payload: { sessionId: string; events: unknown[] }; } | { type: "APPEND_RAW_SDK_EVENT_PAYLOAD"; payload: { sessionId?: string | null; event: unknown } } + | { + type: "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH"; + payload: { sessionId?: string | null; events: unknown[] }; + } | { type: "APPEND_LIVE_EVENT_STREAM_DEBUG"; payload: { sessionId?: string | null; event: unknown } } | { type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" } | { @@ -266,7 +275,6 @@ export type AppAction = type: "SET_QUEUE"; payload: { sessionId: string | null; queue: QueueItem[] }; } - | { type: "ADD_PENDING_DEFERRED_PROMPT"; payload: PendingDeferredPrompt } | { type: "ADD_PENDING_USER_MESSAGE"; payload: PendingUserMessage } | { type: "CONFIRM_PENDING_USER_MESSAGE"; @@ -2423,6 +2431,7 @@ export function mergeStreamingReasoning( current: string, incoming: string, append?: boolean, + delta?: boolean, ): ReasoningMergeResult { const incomingChunk = incoming.trim(); if (!append) { @@ -2439,6 +2448,18 @@ export function mergeStreamingReasoning( return { reasoning: incoming, eventChunk: incomingChunk }; } + // True transport deltas are already ordered and non-overlapping. Avoid + // normalizing and searching the entire accumulated reasoning buffer for + // every token; that made long reasoning streams progressively quadratic + // and could monopolize the webview thread during scroll gestures. + if (delta) { + return { + reasoning: appendStreamingReasoning(current, incoming), + eventChunk: incomingChunk, + replaceLastEvent: false, + }; + } + const currentNorm = normalizeReasoningText(current); const incomingNorm = normalizeReasoningText(incoming); if (!currentNorm) { @@ -2958,6 +2979,40 @@ export function appReducer(state: AppState, action: AppAction): AppState { }, sessionId), }; } + case "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH": { + const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; + const incoming = Array.isArray(action.payload.events) + ? action.payload.events.filter(shouldIncludeCentralizedDebugPayload) + : []; + if (!sessionId || incoming.length === 0) { + return state; + } + const existing = state.rawSdkEventPayloadsBySessionId?.[sessionId] ?? []; + let next = existing; + for (const rawEvent of incoming) { + next = appendAndDedupeCentralizedDebugPayload( + next, + sanitizeCentralizedDebugPayload(rawEvent), + ) as unknown[]; + } + if (next === existing) { + return state; + } + logger.info("[CENTRALIZED-TAPE][WEBVIEW_STORE] append_raw_sdk_event_payload_batch", { + sessionId, + incomingCount: incoming.length, + previousCount: existing.length, + nextCount: next.length, + currentSessionId: state.currentSessionId, + }); + return { + ...state, + rawSdkEventPayloadsBySessionId: pruneSessionCache({ + ...(state.rawSdkEventPayloadsBySessionId ?? {}), + [sessionId]: next, + }, sessionId), + }; + } case "APPEND_LIVE_EVENT_STREAM_DEBUG": { const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; if (!sessionId) { @@ -3281,8 +3336,24 @@ export function appReducer(state: AppState, action: AppAction): AppState { currentSessionId: state.currentSessionId ?? null, }); } - if (!state.streaming) { - if (!action.payload) { + const status = action.payload; + const statusSessionId = status?.sessionId ?? state.currentSessionId; + // A retry status can follow a terminal event from the previous attempt. + // Treat it as a new live phase of that same assistant turn, otherwise the + // StreamingCard's completed-turn guard hides the inline status row. + const keepsAssistantTurnLive = Boolean( + status && status.statusType !== "idle" && status.statusType !== "completed", + ); + const existingSessionStreaming = statusSessionId + ? state.streamingBySessionId?.[statusSessionId] ?? null + : null; + const activeStreaming = + state.currentSessionId === statusSessionId + ? state.streaming + : existingSessionStreaming; + + if (!activeStreaming) { + if (!status) { return state; } const streaming: StreamingState = { @@ -3293,32 +3364,35 @@ export function appReducer(state: AppState, action: AppAction): AppState { steps: [], progressEvents: [], edits: [], - liveSessionStatus: action.payload, - isActive: true, + liveSessionStatus: status, + isActive: keepsAssistantTurnLive, agent: state.selectedAgent || undefined, }; + const streamingBySessionId = cacheStreamingForSession( + state.streamingBySessionId, + statusSessionId, + streaming, + ); return { ...state, - streaming, - streamingBySessionId: cacheStreamingForSession( - state.streamingBySessionId, - state.currentSessionId, - streaming, - ), + ...(state.currentSessionId === statusSessionId ? { streaming } : {}), + streamingBySessionId, }; } const streaming = { - ...state.streaming, - liveSessionStatus: action.payload, + ...activeStreaming, + liveSessionStatus: status, + isActive: keepsAssistantTurnLive ? true : activeStreaming.isActive, }; + const streamingBySessionId = cacheStreamingForSession( + state.streamingBySessionId, + statusSessionId, + streaming, + ); return { ...state, - streaming, - streamingBySessionId: cacheStreamingForSession( - state.streamingBySessionId, - state.currentSessionId, - streaming, - ), + ...(state.currentSessionId === statusSessionId ? { streaming } : {}), + streamingBySessionId, }; } case "APPEND_SDK_EVENT_PAYLOAD": { @@ -3429,6 +3503,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { state.streaming.reasoning, action.payload.reasoning, action.payload.append, + action.payload.delta, ); const reasoning = merged.reasoning; const chunk = merged.eventChunk?.trim() ?? ""; @@ -3462,6 +3537,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { existingText, action.payload.reasoning, action.payload.append, + action.payload.delta, ).reasoning : chunk; const replaceIndex = @@ -3721,32 +3797,6 @@ export function appReducer(state: AppState, action: AppAction): AppState { : state.promptQueue, }; } - case "ADD_PENDING_DEFERRED_PROMPT": { - const item = action.payload; - if (!item.sessionId || !item.id || !item.text.trim()) { - return state; - } - const nextBySession = { ...(state.pendingDeferredPromptsBySessionId ?? {}) }; - const existing = nextBySession[item.sessionId] ?? []; - const alreadyExists = existing.some( - (prompt) => - prompt.id === item.id || - (prompt.clientRequestId && - item.clientRequestId && - prompt.clientRequestId === item.clientRequestId), - ); - if (alreadyExists) { - return state; - } - nextBySession[item.sessionId] = [...existing, item]; - return { - ...state, - pendingDeferredPromptsBySessionId: pruneSessionCache( - nextBySession, - state.currentSessionId, - ), - }; - } case "ADD_PENDING_USER_MESSAGE": { const item = action.payload; if (!item.sessionId || !item.id || !item.text.trim()) { @@ -4157,21 +4207,27 @@ export function appReducer(state: AppState, action: AppAction): AppState { }; } case "UPSERT_SUBAGENT_SUMMARIES": { + const subagentStore = upsertSubagentSummariesIntoStore( + state.subagentStore, + action.payload, + ); return { ...state, - subagentsByParentMessageId: { - ...state.subagentsByParentMessageId, - ...action.payload, - }, + subagentStore, + subagentsByParentMessageId: selectSubagentSummariesByParentMessageId(subagentStore), + subagentDetailsById: subagentStore.byId, }; } case "UPSERT_SUBAGENT_DETAIL": { + const subagentStore = upsertSubagentDetailsIntoStore( + state.subagentStore, + action.payload, + ); return { ...state, - subagentDetailsById: { - ...state.subagentDetailsById, - ...action.payload, - }, + subagentStore, + subagentsByParentMessageId: selectSubagentSummariesByParentMessageId(subagentStore), + subagentDetailsById: subagentStore.byId, }; } case "SELECT_SUBAGENT": { @@ -4183,6 +4239,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { case "CLEAR_SUBAGENTS_FOR_SESSION": { return { ...state, + subagentStore: createSubagentEntityStore(), subagentsByParentMessageId: {}, subagentDetailsById: {}, selectedSubagentId: null, diff --git a/webview/shared/src/chat/lib/subagents/centralExtractor.ts b/webview/shared/src/chat/lib/subagents/centralExtractor.ts index 6dc2bf1..4c82b19 100644 --- a/webview/shared/src/chat/lib/subagents/centralExtractor.ts +++ b/webview/shared/src/chat/lib/subagents/centralExtractor.ts @@ -93,31 +93,31 @@ export function extractSubagentsFromCentralizedEvents( // Use the unified logic to find the ultimate parent message ID // This handles: Tool Event → Assistant Message → User Message - const parentMessageId = findUltimateParentMessageId(subagentEvent, rawSdkEventPayloads); + const resolvedParentMessageId = findUltimateParentMessageId(subagentEvent, rawSdkEventPayloads); - if (!parentMessageId) { + if (!resolvedParentMessageId) { continue; } // Filter by parent message if specified - if (parentMessageId && parentMessageId !== parentMessageId) { + if (parentMessageId && resolvedParentMessageId !== parentMessageId) { continue; } // Get ALL events for this message to capture all related tools - const allRelatedEvents = eventsByMessageId.get(parentMessageId) || []; + const allRelatedEvents = eventsByMessageId.get(resolvedParentMessageId) || []; // Build the subagent detail including all tools (bash, glob, etc.) - const detail = extractSubagentDetailFromCentralizedEvents(allRelatedEvents, callID, parentMessageId); + const detail = extractSubagentDetailFromCentralizedEvents(allRelatedEvents, callID, resolvedParentMessageId); if (detail) { detailsById[detail.id] = detail; // Add to summaries - if (!summariesByParentMessageId[parentMessageId]) { - summariesByParentMessageId[parentMessageId] = []; + if (!summariesByParentMessageId[resolvedParentMessageId]) { + summariesByParentMessageId[resolvedParentMessageId] = []; } - summariesByParentMessageId[parentMessageId].push( + summariesByParentMessageId[resolvedParentMessageId].push( normalizeSubagentSummary(detail) as SubagentSummary ); } @@ -185,6 +185,7 @@ function extractSubagentDetailFromCentralizedEvents( latestActivity: extractLatestActivity(parentEventRecord) || metadata.output || 'Subagent update', references: [], thinkingEvents, + rawEvents: events, conversationEvents, rawConversationEvents: conversationEvents, progressEvents, @@ -240,6 +241,7 @@ function extractSubagentDetailFromEvents( latestActivity: extractLatestActivity(event) || 'Tool activity', references: [], thinkingEvents, + rawEvents: events, conversationEvents, rawConversationEvents: conversationEvents, progressEvents, @@ -262,4 +264,4 @@ export function extractSubagentsFromMessages(messages: unknown[]): { // This would be used when centralized events aren't available return { summariesByParentMessageId, detailsById }; -} \ No newline at end of file +} diff --git a/webview/shared/src/chat/lib/subagents/dataNormalizer.ts b/webview/shared/src/chat/lib/subagents/dataNormalizer.ts index ae364cb..31eb58a 100644 --- a/webview/shared/src/chat/lib/subagents/dataNormalizer.ts +++ b/webview/shared/src/chat/lib/subagents/dataNormalizer.ts @@ -20,7 +20,7 @@ import { asRecord, asString } from '../messageHandler'; * Check if value is a valid subagent status */ export function isSubagentStatus(value: unknown): value is SubagentSummary['status'] { - return value === 'pending' || value === 'running' || value === 'done' || value === 'error' || value === 'orphaned'; + return value === 'pending' || value === 'running' || value === 'done' || value === 'error' || value === 'orphaned' || value === 'cancelled'; } /** @@ -190,6 +190,7 @@ export function normalizeSubagentDetail(value: unknown): SubagentDetail | null { const rawConversationEvents = Array.isArray(rec.conversationEvents) ? [...rec.conversationEvents] : []; + const rawEvents = Array.isArray(rec.rawEvents) ? [...rec.rawEvents] : []; const progressEvents = Array.isArray(rec.progressEvents) ? rec.progressEvents @@ -256,6 +257,7 @@ export function normalizeSubagentDetail(value: unknown): SubagentDetail | null { return { ...summary, thinkingEvents, + rawEvents, conversationEvents, rawConversationEvents, progressEvents: normalizedProgressEvents, @@ -349,4 +351,4 @@ function asBoolean(value: unknown, defaultValue: boolean): boolean { if (lowered === 'false') return false; } return defaultValue; -} \ No newline at end of file +} diff --git a/webview/shared/src/chat/lib/subagents/eventNormalizer.ts b/webview/shared/src/chat/lib/subagents/eventNormalizer.ts index e0ac1f5..99146ed 100644 --- a/webview/shared/src/chat/lib/subagents/eventNormalizer.ts +++ b/webview/shared/src/chat/lib/subagents/eventNormalizer.ts @@ -58,29 +58,9 @@ export function findUltimateParentMessageId(toolEvent: unknown, allEvents: unkno const toolMessageId = extractEventMessageId(event, extractEventPart(event) || {}); if (!toolMessageId) return null; - // Step 2: Find the assistant's message.event to get its parentID (the user's message) - // The assistant message has info.parentID that links back to the user's message - for (const e of allEvents) { - const evt = asRecord(e); - if (!evt) continue; - - // Use existing centralized logic to extract event info - // This handles multiple event formats: properties.info, syncEvent.data.info, etc. - const info = extractCentralizedEventInfo(evt); - if (!info) continue; - - // Check if this event is for the assistant's message - const messageId = asString(info?.id || info?.messageID || info?.messageId); - if (messageId === toolMessageId) { - // Found the assistant's message - return its parentID (user's message) - // This is the key parent-child relationship: assistant.message.parentID = user.message.id - const parentId = asString(info?.parentID || info?.parentId); - if (parentId) return parentId; - } - } - - // Fallback: if we can't find the parent relationship, return the tool's message ID - // This maintains backward compatibility if parentID is missing + // The part is already attached to the assistant message. Do not walk + // `info.parentID`: that identifies the initiating user message and would + // attach this subagent to the wrong transcript block. return toolMessageId; } @@ -293,4 +273,4 @@ export function extractSessionId(event: Record): string | null ]; return candidates.find(id => id && id.trim().length > 0) || null; -} \ No newline at end of file +} diff --git a/webview/shared/src/chat/lib/subagents/hydrationSource.ts b/webview/shared/src/chat/lib/subagents/hydrationSource.ts new file mode 100644 index 0000000..6f5f3f9 --- /dev/null +++ b/webview/shared/src/chat/lib/subagents/hydrationSource.ts @@ -0,0 +1,76 @@ +import type { SubagentDetail, SubagentSummary } from "./types"; +import { extractSubagentsFromCentralizedEvents } from "./centralExtractor"; + +type UnknownRecord = Record; + +export type HydratedSubagentProjection = { + summariesByParentMessageId: Record; + detailsById: Record; +}; + +/** + * Resolve the source for a hydrated subagent view. + * + * The raw SDK tape is authoritative. The persisted projection is intentionally + * a compatibility fallback for historic/orphaned sessions whose event tape + * predates complete parent-child linking. + */ +export function resolveHydratedSubagentProjection( + rawSdkEventPayloads: unknown[], + persisted?: UnknownRecord, +): HydratedSubagentProjection { + const fromRaw = extractSubagentsFromCentralizedEvents(rawSdkEventPayloads); + const persistedDetails = + (persisted?.detailsById as Record | undefined) || {}; + const persistedSummaries = + (persisted?.summariesByParentMessageId as Record | undefined) || {}; + const terminalPersistedDetails = Object.fromEntries( + Object.entries(persistedDetails).filter(([, detail]) => detail?.status === "cancelled"), + ); + if ( + Object.keys(fromRaw.summariesByParentMessageId).length > 0 || + Object.keys(fromRaw.detailsById).length > 0 + ) { + // The raw tape owns activity data, but it has no lifecycle boundary at a + // host restart. A persisted cancellation is therefore authoritative for + // that one terminal state; otherwise an old `running` tool event revives. + if (Object.keys(terminalPersistedDetails).length === 0) { + return fromRaw; + } + + const detailsById = { ...fromRaw.detailsById, ...terminalPersistedDetails }; + const summariesByParentMessageId = Object.fromEntries( + Object.entries(fromRaw.summariesByParentMessageId).map(([parentId, summaries]) => [ + parentId, + summaries.map((summary) => { + const terminalDetail = detailsById[summary.id]; + return terminalDetail?.status === "cancelled" + ? { ...summary, status: "cancelled" as const, latestActivity: "Cancelled" } + : summary; + }), + ]), + ); + // Retain cancelled projections which the raw tape cannot associate with a + // parent anymore (a common shape in older cached sessions). + for (const [parentId, summaries] of Object.entries(persistedSummaries)) { + const missing = summaries.filter((summary) => + summary.status === "cancelled" && + !summariesByParentMessageId[parentId]?.some((current) => current.id === summary.id), + ); + if (missing.length > 0) { + summariesByParentMessageId[parentId] = [ + ...(summariesByParentMessageId[parentId] || []), + ...missing, + ]; + } + } + return { summariesByParentMessageId, detailsById }; + } + + return { + summariesByParentMessageId: + persistedSummaries, + detailsById: + persistedDetails, + }; +} diff --git a/webview/shared/src/chat/lib/subagents/stateManager.ts b/webview/shared/src/chat/lib/subagents/stateManager.ts index d22692f..d8b3f7b 100644 --- a/webview/shared/src/chat/lib/subagents/stateManager.ts +++ b/webview/shared/src/chat/lib/subagents/stateManager.ts @@ -8,6 +8,7 @@ import type { SubagentSummary, SubagentDetail, + SubagentEntityStore, } from './types'; import { asRecord, asString, asNumber } from '../messageHandler'; import { sanitizeSubagentLabel } from './dataNormalizer'; @@ -16,6 +17,133 @@ import { normalizeSubagentTimelineEventsForPresentation } from './eventBuilder'; +function subagentStatusRank(status: SubagentSummary["status"] | undefined): number { + switch (status) { + case "done": + case "error": + case "orphaned": + case "cancelled": + return 2; + case "running": + return 1; + default: + return 0; + } +} + +/** Do not let an older live projection revive an already-terminal subagent. */ +function mergeSubagentStatus( + existing: SubagentSummary["status"] | undefined, + incoming: SubagentSummary["status"] | undefined, +): SubagentSummary["status"] { + if (!incoming) return existing || "pending"; + return subagentStatusRank(existing) > subagentStatusRank(incoming) + ? existing! + : incoming; +} + +function emptySubagentEventCollections(): Pick { + // Each stub must own its collections: streaming merges append/replace arrays + // over time, and shared empty array references make that behavior fragile. + return { + references: [], + thinkingEvents: [], + conversationEvents: [], + rawEvents: [], + progressEvents: [], + timelineEvents: [], + }; +} + +export function createSubagentEntityStore(): SubagentEntityStore { + return { + version: 1, + byId: {}, + idsByParentMessageId: {}, + idByChildSessionId: {}, + updatedAt: 0, + }; +} + +function detailFromSummary(summary: SubagentSummary): SubagentDetail { + return { + ...summary, + ...emptySubagentEventCollections(), + references: [...summary.references], + }; +} + +function buildSubagentIndexes(byId: Record): Pick { + const idsByParentMessageId: Record = {}; + const idByChildSessionId: Record = {}; + + for (const detail of Object.values(byId)) { + if (!detail?.id) continue; + if (detail.parentMessageId) { + (idsByParentMessageId[detail.parentMessageId] ??= []).push(detail.id); + } + if (detail.childSessionId) { + idByChildSessionId[detail.childSessionId] = detail.id; + } + } + + return { idsByParentMessageId, idByChildSessionId }; +} + +function finalizeSubagentEntityStore(byId: Record): SubagentEntityStore { + return { + version: 1, + byId, + ...buildSubagentIndexes(byId), + updatedAt: Date.now(), + }; +} + +/** Merge incoming compatibility summaries into the canonical entity store. */ +export function upsertSubagentSummariesIntoStore( + store: SubagentEntityStore, + incomingByParentId: Record, +): SubagentEntityStore { + const byId = { ...store.byId }; + for (const summaries of Object.values(incomingByParentId)) { + for (const summary of summaries ?? []) { + if (!summary?.id) continue; + byId[summary.id] = mergeSubagentDetailRecord( + byId[summary.id], + detailFromSummary(summary), + ); + } + } + return finalizeSubagentEntityStore(byId); +} + +/** Merge incoming detailed records into the canonical entity store. */ +export function upsertSubagentDetailsIntoStore( + store: SubagentEntityStore, + incomingById: Record, +): SubagentEntityStore { + const byId = { ...store.byId }; + for (const detail of Object.values(incomingById)) { + if (!detail?.id) continue; + byId[detail.id] = mergeSubagentDetailRecord(byId[detail.id], detail); + } + return finalizeSubagentEntityStore(byId); +} + +/** Compatibility selector for the existing message-oriented UI. */ +export function selectSubagentSummariesByParentMessageId( + store: SubagentEntityStore, +): Record { + const summaries: Record = {}; + for (const [parentMessageId, ids] of Object.entries(store.idsByParentMessageId)) { + const entries = ids.map((id) => store.byId[id]).filter((detail): detail is SubagentDetail => Boolean(detail)); + if (entries.length > 0) summaries[parentMessageId] = entries; + } + return summaries; +} + /** * Merge subagent summaries with existing state */ @@ -23,12 +151,6 @@ export function mergeSubagentSummaries( existing: SubagentSummary[] | undefined, incoming: SubagentSummary[], ): SubagentSummary[] { - const statusRank = (status: SubagentSummary["status"] | undefined): number => { - if (status === "done" || status === "error" || status === "orphaned") return 2; - if (status === "running") return 1; - return 0; - }; - const byId = new Map(); const source = Array.isArray(existing) ? existing : []; @@ -50,9 +172,7 @@ export function mergeSubagentSummaries( // Merge with preference for higher rank status const merged = { ...prev, ...entry, id: entry.id }; - if (statusRank(prev.status) > statusRank(entry.status)) { - merged.status = prev.status; - } + merged.status = mergeSubagentStatus(prev.status, entry.status); byId.set(entry.id, merged); }); @@ -79,7 +199,7 @@ export function mergeUniqueSubagentEntries( keyBuilder: (item: T, index: number) => string, ): T[] { const out: T[] = []; - const byKey = new Map(); + const indexByKey = new Map(); const push = (items: T[] | undefined) => { if (!Array.isArray(items) || items.length === 0) { @@ -93,18 +213,14 @@ export function mergeUniqueSubagentEntries( return; } - if (byKey.has(key)) { - const existingItemIndex = out.findIndex((entry, entryIndex) => { - const entryKey = keyBuilder(entry, entryIndex); - return entryKey === key; - }); - if (existingItemIndex >= 0) { - out[existingItemIndex] = item; - } - } else { - out.push(item); + const existingItemIndex = indexByKey.get(key); + if (typeof existingItemIndex === "number") { + out[existingItemIndex] = item; + return; } - byKey.set(key, item); + + indexByKey.set(key, out.length); + out.push(item); }); }; @@ -159,6 +275,15 @@ export function mergeSubagentDetailRecord( }, ); + const rawEvents = mergeUniqueSubagentEntries( + existing?.rawEvents, + incoming.rawEvents, + (event, index) => { + const rec = asRecord(event); + return asString(rec?.id) || `${asString(rec?.type)}:${index}`; + }, + ); + const progressEvents = normalizeSubagentProgressEventsForPresentation( mergeUniqueSubagentEntries( existing?.progressEvents, @@ -188,12 +313,13 @@ export function mergeSubagentDetailRecord( incoming.parentSessionId || existing?.parentSessionId || "", parentMessageId: incoming.parentMessageId || existing?.parentMessageId || "", - status: incoming.status || existing?.status || "pending", + status: mergeSubagentStatus(existing?.status, incoming.status), latestActivity, references, thinkingEvents, conversationEvents, rawConversationEvents, + rawEvents, progressEvents, timelineEvents, }; @@ -352,4 +478,4 @@ export function updateSubagentStatus( ? summary.durationMs || (summary.startedAt ? Date.now() - summary.startedAt : undefined) : summary.durationMs, }; -} \ No newline at end of file +} diff --git a/webview/shared/src/chat/lib/subagents/types.ts b/webview/shared/src/chat/lib/subagents/types.ts index 7d31759..bb24ebc 100644 --- a/webview/shared/src/chat/lib/subagents/types.ts +++ b/webview/shared/src/chat/lib/subagents/types.ts @@ -8,7 +8,7 @@ /** * Current lifecycle status of a subagent session. */ -export type SubagentStatus = 'pending' | 'running' | 'done' | 'error' | 'orphaned'; +export type SubagentStatus = 'pending' | 'running' | 'done' | 'error' | 'orphaned' | 'cancelled'; /** * Reference identifiers for connecting subagent events to parent message/parts. @@ -96,6 +96,8 @@ export interface SubagentSummary { * Detailed subagent information including all events and metadata. */ export interface SubagentDetail extends SubagentSummary { + /** Same SDK event-tape shape used by Message.rawSdkEventPayloads. */ + rawEvents?: unknown[]; thinkingEvents: SubagentThinkingEvent[]; conversationEvents?: SubagentConversationEvent[]; rawConversationEvents?: unknown[]; @@ -111,6 +113,22 @@ export interface SubagentDetail extends SubagentSummary { hydrationUnavailable?: boolean; } +/** + * Canonical normalized subagent state. + * + * `byId` is the only mutable source of truth. The message and child-session + * indexes exist solely for efficient rendering and late session binding. + * Legacy summary/detail maps are derived from this store for compatibility + * with the existing chat components. + */ +export interface SubagentEntityStore { + version: 1; + byId: Record; + idsByParentMessageId: Record; + idByChildSessionId: Record; + updatedAt: number; +} + /** * Normalized event structure for subagent extraction processing. */ @@ -130,7 +148,10 @@ export interface NormalizedSubagentEvent { * Store state structure for subagent data. */ export interface SubagentState { + subagentStore: SubagentEntityStore; + /** @deprecated Derived compatibility projection. Do not mutate directly. */ subagentsByParentMessageId: Record; + /** @deprecated Derived compatibility projection. Do not mutate directly. */ subagentDetailsById: Record; selectedSubagentId: string | null; subagentsPanelOpen: boolean; @@ -153,4 +174,4 @@ export type SubagentAction = | { type: 'UPSERT_SUBAGENT_DETAIL'; payload: Record } | { type: 'SELECT_SUBAGENT'; payload: string | null } | { type: 'SET_SUBAGENTS_PANEL_OPEN'; payload: boolean } - | { type: 'CLEAR_SUBAGENTS_FOR_SESSION'; payload: string }; \ No newline at end of file + | { type: 'CLEAR_SUBAGENTS_FOR_SESSION'; payload: string }; diff --git a/webview/shared/src/chat/lib/subagents/uiFormatter.ts b/webview/shared/src/chat/lib/subagents/uiFormatter.ts index bb500e3..3b79739 100644 --- a/webview/shared/src/chat/lib/subagents/uiFormatter.ts +++ b/webview/shared/src/chat/lib/subagents/uiFormatter.ts @@ -60,7 +60,8 @@ export function getSubagentDisplayActivity( if ( (resolvedStatus === "done" || resolvedStatus === "error" || - resolvedStatus === "orphaned") && + resolvedStatus === "orphaned" || + resolvedStatus === "cancelled") && hasStaleNonTerminalActivity ) { return statusText; @@ -138,7 +139,7 @@ export function shouldFreezeSubagentForPresentation( export function resolveDisplayStatus(detail: SubagentDetail): SubagentStatus { const status = (detail.status || "running").toLowerCase() as SubagentStatus; - if (status === "error" || status === "orphaned" || status === "pending") { + if (status === "error" || status === "orphaned" || status === "pending" || status === "cancelled") { return status; } @@ -248,6 +249,7 @@ export function formatSubagentStatus(status: SubagentStatus): string { 'done': 'Complete', 'error': 'Error', 'orphaned': 'Orphaned', + 'cancelled': 'Cancelled', }; return statusDisplayNames[status] || status; @@ -287,7 +289,7 @@ export function isSubagentActive(detail: SubagentDetail): boolean { */ export function isSubagentTerminal(detail: SubagentDetail): boolean { const status = resolveDisplayStatus(detail); - return status === 'done' || status === 'error' || status === 'orphaned'; + return status === 'done' || status === 'error' || status === 'orphaned' || status === 'cancelled'; } /** @@ -300,6 +302,7 @@ export function getSubagentStatusColor(status: SubagentStatus): string { 'done': 'text-green-600', 'error': 'text-red-600', 'orphaned': 'text-gray-600', + 'cancelled': 'text-gray-600', }; return colorMap[status] || 'text-gray-600'; @@ -357,4 +360,4 @@ function firstFinite(...values: unknown[]): number | undefined { } } return undefined; -} \ No newline at end of file +} diff --git a/webview/shared/src/chat/lib/transcriptMessageClassification.ts b/webview/shared/src/chat/lib/transcriptMessageClassification.ts new file mode 100644 index 0000000..da76f46 --- /dev/null +++ b/webview/shared/src/chat/lib/transcriptMessageClassification.ts @@ -0,0 +1,94 @@ +import { + getBackgroundTaskReminderTaskId, + hasBackgroundTaskLaunchForTaskId, + isBackgroundTaskChildAssistantMessage, + isBackgroundTaskReminderMessage, + isCrossSessionSubagentMessage, + isSubagentInitiatorMessage, +} from "./backgroundTaskOwnership"; + +import type { Message } from "./types"; + +export type TranscriptMessageRenderKind = + | "user" + | "assistant" + | "system" + | "permission" + | "background-task-reminder" + | "hidden"; + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +/** Server-authored mode/context directives use a user transport role. */ +export function isExplicitSystemTransportText(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + return ( + /^\[[a-z][a-z0-9_\- ]*\]/i.test(trimmed) || + /^<[a-z][a-z0-9_\-]*>/i.test(trimmed) || + /^