From e8221d3e7e99f2ee519498c4bef99a794778f335 Mon Sep 17 00:00:00 2001 From: Christian Rey Villablanca Date: Tue, 16 Jun 2026 00:11:44 +0800 Subject: [PATCH 01/17] refactor(chat): restructure message processing and event lifecycle handling --- LOGGING_BEST_PRACTICES.md | 198 ++ package.json | 26 +- src/providers/.gitkeep | 1 + src/providers/ChatViewProvider.ts | 533 ++-- src/providers/SkillsPanelProvider.ts | 35 +- src/providers/chat/HistoryProcessor.ts | 17 + src/providers/chat/ModelAndAgentManager.ts | 4 +- src/providers/chat/SessionHandler.ts | 15 +- src/providers/chat/StreamEventHandler.ts | 39 +- src/services/.gitkeep | 1 + src/services/MessageStreamService.ts | 184 +- src/services/ModelCapabilitiesService.ts | 2 +- src/services/OpencodeServerManager.ts | 16 +- src/services/QuotaService.ts | 2 +- src/services/SessionService.ts | 280 +- src/services/SkillManagerService.ts | 2 +- src/shared/.gitkeep | 1 + src/types/.gitkeep | 1 + src/utils/.gitkeep | 1 + .../models-timeout-fallback.test.mjs | 6 +- ...lifecycle-msg-coalesce-regression.test.mjs | 39 +- ...ponse-debug-visibility-regression.test.mjs | 21 +- ...ing-leak-response-card-regression.test.mjs | 28 +- ...ssage-canonicalization-regression.test.mjs | 6 +- ...-message-deduplication-regression.test.mjs | 2 +- tests/services/quota-service.test.mjs | 2 +- tests/unit/config/timeout-validation.test.mjs | 2 +- .../history-processor-regression.test.mjs | 5 + ...pencode-server-manager-regression.test.mjs | 26 +- tests/unit/model-agent-manager.test.mjs | 6 +- tests/unit/services/SessionService.test.mjs | 25 + .../unit/streaming-stop-button-sync.test.mjs | 6 +- .../system-message-rendering-fix.test.mjs | 2 +- tests/webview/chat-flow-integration.test.mjs | 111 +- tests/webview/chat-message-flow.test.mjs | 6 +- tests/webview/chat-view-streaming.test.mjs | 42 + .../chatshell-rendering-conditions.test.mjs | 19 +- tests/webview/input-interactive-flow.test.mjs | 14 +- .../raw-data-capture-regression.test.mjs | 58 + .../stepper-autoscroll-and-flow.test.mjs | 25 +- tests/webview/streaming-components.test.mjs | 21 +- webview/shared/package-lock.json | 47 +- webview/shared/package.json | 1 - webview/shared/src/chat/ChatShell.tsx | 166 +- webview/shared/src/chat/MessageComponents.tsx | 2733 +++++++++++------ webview/shared/src/chat/PanelComponents.tsx | 210 +- .../shared/src/chat/StreamingComponents.tsx | 62 +- webview/shared/src/chat/ToastOverlay.tsx | 194 ++ webview/shared/src/chat/lib/messageHandler.ts | 1092 +++---- webview/shared/src/chat/lib/rawResponse.ts | 510 +++ .../shared/src/chat/lib/sessionProcessing.ts | 75 + webview/shared/src/chat/lib/store.test.ts | 63 +- webview/shared/src/chat/lib/store.ts | 226 +- webview/shared/src/chat/lib/toastEvents.ts | 103 + webview/shared/src/chat/lib/types.ts | 88 +- webview/shared/src/config.ts | 4 +- .../src/diff-review/DiffReviewShell.tsx | 1052 ------- webview/shared/src/diff-review/index.tsx | 15 - webview/shared/src/plan/PlanShell.tsx | 899 ------ webview/shared/src/plan/index.tsx | 15 - webview/shared/src/plan/markdownRenderer.ts | 8 - webview/shared/src/skills/SkillsShell.tsx | 463 --- webview/shared/src/skills/index.tsx | 16 - webview/shared/vite.config.ts | 6 +- 64 files changed, 5242 insertions(+), 4636 deletions(-) create mode 100644 LOGGING_BEST_PRACTICES.md create mode 100644 src/providers/.gitkeep create mode 100644 src/services/.gitkeep create mode 100644 src/shared/.gitkeep create mode 100644 src/types/.gitkeep create mode 100644 src/utils/.gitkeep create mode 100644 tests/webview/raw-data-capture-regression.test.mjs create mode 100644 webview/shared/src/chat/ToastOverlay.tsx create mode 100644 webview/shared/src/chat/lib/rawResponse.ts create mode 100644 webview/shared/src/chat/lib/toastEvents.ts delete mode 100644 webview/shared/src/diff-review/DiffReviewShell.tsx delete mode 100644 webview/shared/src/diff-review/index.tsx delete mode 100644 webview/shared/src/plan/PlanShell.tsx delete mode 100644 webview/shared/src/plan/index.tsx delete mode 100644 webview/shared/src/plan/markdownRenderer.ts delete mode 100644 webview/shared/src/skills/SkillsShell.tsx delete mode 100644 webview/shared/src/skills/index.tsx diff --git a/LOGGING_BEST_PRACTICES.md b/LOGGING_BEST_PRACTICES.md new file mode 100644 index 0000000..57e4d29 --- /dev/null +++ b/LOGGING_BEST_PRACTICES.md @@ -0,0 +1,198 @@ +# Logging Best Practices Guide + +This guide provides best practices for structured logging in the OpenCode VSCode extension to ensure logs are readable, searchable, and useful for debugging. + +## Core Principles + +### 1. Use Structured Messages +❌ **Bad:** `Session ${session.id}: ${existingMessages.length} existing messages` +✅ **Good:** `"Session message context loaded"` with context `{ sessionId, existingMessageCount, isNewSession }` + +**Why:** Structured messages are parseable by log analysis tools and easier to search. + +### 2. Keep Messages Action-Oriented +❌ **Bad:** `"handleGetMcpStatus error"` +✅ **Good:** `"Failed to get MCP server status"` + +**Why:** Action-oriented messages clearly describe what happened or what failed. + +### 3. Move Dynamic Data to Context +❌ **Bad:** `log.error("Failed to read file ${filePath}", { filePath })` +✅ **Good:** `log.error("Failed to read attached file", { filePath, error })` + +**Why:** Keeps message format consistent and makes logs easier to parse. + +### 4. Use Appropriate Log Levels +- **error**: Operation failed and functionality is broken +- **warn**: Potential issue but operation continues (fallbacks, retries) +- **info**: Normal operation milestones and state changes +- **debug**: Detailed diagnostic information for troubleshooting + +### 5. Include Relevant Context +Always include relevant identifiers and metadata: +```typescript +log.error("Failed to abort active request", { + sessionId: resolvedSessionId, + error: error instanceof Error ? error.message : String(error), +}, error as Error); +``` + +### 6. Use Feature Flow Tracking +For multi-step operations, use feature flow tracking: +```typescript +const flow = log.startFeatureFlow('DataImport', { source: 'api' }); +log.featureStep(flow, 'validation', { valid: 95, invalid: 5 }); +log.endFeatureFlow(flow, { success: true, imported: 95 }); +``` + +## Message Format Patterns + +### Error Messages +**Pattern:** `"Failed to "` +```typescript +log.error("Failed to read attached image", { + filePath: uri.fsPath, + error: error.message, +}, error); +``` + +### Warning Messages +**Pattern:** `" failed, "` or `"Failed to , "` +```typescript +log.warn("SDK file search failed, using VS Code fallback", { + query, + error: error.message, +}); +``` + +### Info Messages +**Pattern:** `" past tense>"` or `" "` +```typescript +log.info("MCP server status sent to webview", { + serverCount: Object.keys(servers).length, + toolCount: toolIds.length, +}); +``` + +### Debug Messages +**Pattern:** `" "` or `" completed"` +```typescript +log.debug("Session message context loaded", { + sessionId: session.id, + existingMessageCount: existingMessages.length, + isNewSession, +}); +``` + +## Context Object Guidelines + +### Always Include +- **Identifiers**: `sessionId`, `filePath`, `messageId`, `correlationId` +- **Error details**: `error: error.message` (if Error object) +- **Counts/quantities**: `count`, `duration`, `size` + +### Naming Convention +Use **camelCase** for context keys: +```typescript +✅ { sessionId, existingMessageCount, isNewSession } +❌ { session_id, existing_message_count, is_new_session } +``` + +### Derived Data +Calculate and include relevant derived values: +```typescript +log.debug("AI response received", { + sessionId: session.id, + durationSeconds: duration, // Calculated value + hasData: Boolean(responseData), + status: response.response?.status, +}); +``` + +## Error Handling Patterns + +### With Error Object +```typescript +} catch (error) { + log.error("Failed to read attached file", { + filePath, + error: error instanceof Error ? error.message : String(error), + }, error as Error); +} +``` + +### Without Error Object +```typescript +} catch (error) { + log.error("Failed to abort active request", { + sessionId: resolvedSessionId, + error: error instanceof Error ? error.message : String(error), + }, error as Error); +} +``` + +## Feature Flow Examples + +### Simple Flow +```typescript +const flow = log.startFeatureFlow('FileSearch', { query }); +try { + const results = await searchFiles(query); + log.endFeatureFlow(flow, { + result: 'completed', + resultCount: results.length + }); +} catch (error) { + log.endFeatureFlow(flow, { + result: 'failed', + error: String(error) + }); +} +``` + +### Multi-Step Flow +```typescript +const flow = log.startFeatureFlow('DataImport', { source: 'api' }); +log.featureStep(flow, 'validation', { valid: 95, invalid: 5 }); +log.featureStep(flow, 'transformation', { transformed: 95 }); +log.featureStep(flow, 'loading', { loaded: 95 }); +log.endFeatureFlow(flow, { success: true, imported: 95 }); +``` + +## Searchable Logs + +Good logs make it easy to: +1. **Find errors:** Search for "Failed" or "error" level +2. **Track sessions:** Search for specific `sessionId` +3. **Measure performance:** Search for "duration" or "performance" +4. **Debug features:** Search for feature names in feature flows + +## Console Output Examples + +### Pretty Mode (default) +``` +[14:23:45.123] ❌ [ERROR] [ChatView] Failed to read attached file {"filePath":"src/app.ts","error":"ENOENT"} +[14:23:45.456] ⚠️ [WARN] [ChatView] SDK file search failed, using VS Code fallback {"query":"test"} +[14:23:46.789] ℹ️ [INFO] [ChatView] MCP server status sent to webview {"serverCount":3,"toolCount":15} +[14:23:47.012] 🔍 [DEBUG] [ChatView] Session message context loaded {"sessionId":"abc-123","existingMessageCount":42} +``` + +### JSON Mode +```json +{"timestamp":"2026-06-07T14:23:45.123Z","level":"error","category":"ChatView","message":"Failed to read attached file","context":{"filePath":"src/app.ts","error":"ENOENT"}} +``` + +## Common Mistakes to Avoid + +1. **Embedding data in messages:** Don't use template strings for dynamic values +2. **Vague messages:** Avoid "error occurred" or "failed" without context +3. **Missing identifiers:** Always include relevant IDs (sessionId, filePath, etc.) +4. **Inconsistent naming:** Use the same terminology across logs +5. **Wrong log levels:** Don't use `error` for recoverable issues + +## Testing Your Logs + +1. **Enable JSON mode** and verify logs parse correctly +2. **Search for specific patterns** to ensure they're found +3. **Check console output** in both pretty and hybrid modes +4. **Verify error context** includes all relevant information diff --git a/package.json b/package.json index d75e9b9..12adc56 100644 --- a/package.json +++ b/package.json @@ -236,15 +236,15 @@ "maximum": 0.99, "description": "Fraction of model context limit at which auto-compaction fires (0.9 = 90%)" }, - "opencode.requestTimeout": { - "type": "number", - "default": 120000, - "minimum": 10000, - "maximum": 600000, - "description": "Request timeout in milliseconds for API calls (default: 120s, min: 10s, max: 10min)", - "scope": "window", - "order": 10 - }, + "opencode.requestTimeout": { + "type": "number", + "default": 60000, + "minimum": 10000, + "maximum": 600000, + "description": "Request timeout in milliseconds for API calls (default: 60s, min: 10s, max: 10min)", + "scope": "window", + "order": 10 + }, "opencode.complexQueryMultiplier": { "type": "number", "default": 1.5, @@ -262,15 +262,15 @@ } } }, - "scripts": { - "build": "npm run structured-output:sync && npm run webview:build && npm run compile", - "verify": "npm run structured-output:check && npm run build && npm run lint && npm test", + "scripts": { + "build": "npm run webview:build && npm run compile", + "verify": "npm run build && npm run lint && npm test", "vscode:prepublish": "npm run build", "compile": "node esbuild.config.cjs", "watch": "node esbuild.config.cjs --watch", "dev": "node scripts/dev-simple.mjs", "dev:full": "node scripts/dev-hotreload.mjs", - "webview:build": "npm run structured-output:sync && npm --prefix webview/shared run build", + "webview:build": "npm --prefix webview/shared run build", "webview:watch": "npm --prefix webview/shared run build -- --watch", "structured-output:sync": "node scripts/sync-structured-output-contract.mjs", "structured-output:check": "node scripts/sync-structured-output-contract.mjs --check", diff --git a/src/providers/.gitkeep b/src/providers/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/providers/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index d29294f..24c094b 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -222,8 +222,9 @@ export class ChatViewProvider /** Service for streaming events from the server */ private streamService: MessageStreamService; - /** Unsubscribe function for stream service cleanup */ - private unsubscribe?: () => void; + /** Unsubscribe function for stream service cleanup */ + private unsubscribe?: () => void; + private quotaServiceListener?: vscode.Disposable; /** Service for monitoring AI platform quota usage */ private quotaService: QuotaService; @@ -571,15 +572,15 @@ export class ChatViewProvider this.subagentTracker = new SubagentTracker(() => this.selectedModel); this.configFilesProvider = new ConfigFilesProvider(); this.skillManager = new SkillManagerService(context); - this.skillManager.initialize().catch((error) => { - this.logger.error('Failed to initialize skill manager', error); - }); + this.skillManager.initialize().catch((error) => { + this.logger.error('Failed to initialize skill manager', error); + }); // Use injected service or create local instance as fallback - this.modelCapabilitiesService = modelCapabilitiesService ?? new ModelCapabilitiesService(); - this.geminiTokenTracker = GeminiTokenUsageTracker.getInstance(); - this.quotaService.on("quotaUpdate", (data) => { - this.view?.webview.postMessage({ type: "quotaData", data }); - }); + this.modelCapabilitiesService = modelCapabilitiesService ?? new ModelCapabilitiesService(); + this.geminiTokenTracker = GeminiTokenUsageTracker.getInstance(); + this.quotaServiceListener = this.quotaService.on("quotaUpdate", (data) => { + this.view?.webview.postMessage({ type: "quotaData", data }); + }); // Initialize file theme processor this.fileThemeProcessor = new FileThemeProcessor(context); @@ -723,15 +724,16 @@ export class ChatViewProvider ); // 10. StreamEventHandler - this.streamEventHandler = new StreamEventHandler( - this.structuredOutputProcessor, - this.subagentPersistence, - this.compactionManager, - this.diagnosticsLogger, - this.geminiTokenTracker, - this.subagentTracker, - logger, - ); + this.streamEventHandler = new StreamEventHandler( + this.structuredOutputProcessor, + this.subagentPersistence, + this.compactionManager, + this.diagnosticsLogger, + this.geminiTokenTracker, + this.sessionService, + this.subagentTracker, + logger, + ); /** ===== NEW: Wire all callbacks ===== */ this.wireModuleCallbacks(); @@ -1293,20 +1295,21 @@ export class ChatViewProvider // Lines 8274-8278: Session switch detection logic // ============================================================================ - // Step 1: Load and process messages for the new session - const rawMessages = await this.sessionService.getMessages(sessionId); - - this.logger.info('[handleLoadSession] Fetched raw messages', { - sessionId, - rawCount: rawMessages?.length || 0, - isRawMessagesArray: Array.isArray(rawMessages) + // Step 1: Load and process messages for the new session + const rawMessages = await this.sessionService.getMessages(sessionId); + const rawSessionPayloads = await this.loadSessionDebugPayloads(sessionId); + + this.logger.debug('[handleLoadSession] Fetched raw messages', { + sessionId, + rawCount: rawMessages?.length || 0, + isRawMessagesArray: Array.isArray(rawMessages) }); const messages = Array.isArray(rawMessages) ? await this.processHistoryMessages(rawMessages, sessionId) : []; - this.logger.info('[handleLoadSession] Processed messages', { + this.logger.debug('[handleLoadSession] Processed messages', { sessionId, processedCount: messages.length, willSendToWebview: true @@ -1333,12 +1336,14 @@ export class ChatViewProvider // Step 4: Send chatHistory FIRST (before initState) // This ensures the webview can detect the session switch properly - this.view?.webview.postMessage({ - type: "chatHistory", - sessionId: sessionId, - messages: messages, - processingSessionIds: this.getEffectiveProcessingSessionIds(), - }); + this.view?.webview.postMessage({ + type: "chatHistory", + sessionId: sessionId, + messages: messages, + rawMessages: rawSessionPayloads.rawMessages, + rawSdkEventPayloads: rawSessionPayloads.rawSdkEventPayloads, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + }); await this.compactionManager.sendCompactionViewStateForMessages( sessionId, messages, @@ -2607,42 +2612,37 @@ export class ChatViewProvider } }); - // Fetch and send chat history and sessions list - if (currentSession) { - this.subagentTracker.setActiveSession(currentSession.id); - const rawMessages = await this.sessionService.getMessages( - currentSession.id, - ); - const messages = await this.processHistoryMessages( - rawMessages, - currentSession.id, - ); - this.logHistoryRenderDiagnostics( - "webview.ready.current-session", - currentSession.id, - rawMessages, - messages, - ); - const assistantMessages = messages.filter((m: any) => { - const role = this.firstNonEmptyString(m?.role, m?.info?.role); - return role?.toLowerCase() === "assistant"; - }); - this.logger.debug("[CLIENT FACING] SENDING chatHistory", { - sessionId: currentSession.id, - totalMessages: messages.length, - assistantMessages: assistantMessages.map((m: any) => ({ - id: m?.id || m?.info?.id, - content: String(m?.content).slice(0, 150), - structOutMsg: String(m?.structuredOutput?.message).slice(0, 150), - hasRawResponse: !!m?.rawResponse, - })), - }); - this.view?.webview.postMessage({ - type: "chatHistory", - sessionId: currentSession.id, - messages: messages, - processingSessionIds: this.getEffectiveProcessingSessionIds(), - }); + // Fetch and send chat history and sessions list + if (currentSession) { + this.subagentTracker.setActiveSession(currentSession.id); + const rawMessages = await this.sessionService.getMessages( + currentSession.id, + ); + const rawSessionPayloads = await this.loadSessionDebugPayloads( + currentSession.id, + ); + const rawHistoryMessages = rawSessionPayloads.rawMessages; + const historyMessages = rawHistoryMessages.length > 0 + ? rawHistoryMessages + : rawMessages; + const messages = await this.processHistoryMessages( + historyMessages, + currentSession.id, + ); + this.logHistoryRenderDiagnostics( + "webview.ready.current-session", + currentSession.id, + historyMessages, + messages, + ); + this.view?.webview.postMessage({ + type: "chatHistory", + sessionId: currentSession.id, + messages: messages, + rawMessages: rawHistoryMessages, + rawSdkEventPayloads: rawSessionPayloads.rawSdkEventPayloads, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + }); await this.sendPersistedCompactionViewState(currentSession.id); const subagentSnapshotPayload = await this.syncSubagentSnapshotForSession( @@ -2816,19 +2816,20 @@ export class ChatViewProvider } break; } - case "persistAssistantMessage": { - const sessionId = this.firstNonEmptyString( - message.sessionId, - this.currentSessionId, - ); - if (!sessionId || !message.message) { - break; - } - const persistedMessage = - message.message && typeof message.message === "object" - ? { - ...message.message, - sessionID: this.firstNonEmptyString( + case "persistAssistantMessage": { + const sessionId = this.firstNonEmptyString( + message.sessionId, + this.currentSessionId, + ); + if (!sessionId || !message.message) { + break; + } + const rawMessage = message.rawMessage; + const persistedMessage = + message.message && typeof message.message === "object" + ? { + ...message.message, + sessionID: this.firstNonEmptyString( message.message?.sessionID, message.message?.sessionId, message.message?.info?.sessionID, @@ -2837,21 +2838,38 @@ export class ChatViewProvider ), createdAt: this.historyMessageCreatedAt(message.message) ?? Date.now(), - } - : message.message; - await this.sessionService.upsertMessage(sessionId, persistedMessage); - await this.persistSessionMessageOverride(sessionId, persistedMessage); - const snapshotFromMessage = this.buildSubagentPayloadFromMessage( - persistedMessage, - sessionId, + } + : message.message; + if (typeof rawMessage !== "undefined") { + await this.sessionService.appendRawMessage(sessionId, rawMessage); + } + await this.sessionService.upsertMessage(sessionId, persistedMessage); + await this.persistSessionMessageOverride(sessionId, persistedMessage); + const snapshotFromMessage = this.buildSubagentPayloadFromMessage( + persistedMessage, + sessionId, ); - if (snapshotFromMessage) { - await this.persistSubagentLiveState(sessionId, snapshotFromMessage); - } - break; - } - case "newSession": - case "createSession": { + if (snapshotFromMessage) { + await this.persistSubagentLiveState(sessionId, snapshotFromMessage); + } + break; + } + case "persistRawSdkEventPayload": { + const sessionId = this.firstNonEmptyString( + message.sessionId, + this.currentSessionId, + ); + if (!sessionId || typeof message.event === "undefined") { + break; + } + await this.sessionService.appendRawSdkEventPayload( + sessionId, + message.event, + ); + break; + } + case "newSession": + case "createSession": { const correlationId = this.logger.startFeatureFlow('create-session'); this.logger.info(`${LoggingCategories.UI_INTERACTION} User creating new session`, { @@ -2880,11 +2898,14 @@ export class ChatViewProvider // Apply this before refresh so the UI updates immediately. this.refreshView(); - // Clear webview messages - this.view?.webview.postMessage({ - type: "chatHistory", - messages: [], - }); + // Clear webview messages + this.view?.webview.postMessage({ + type: "chatHistory", + sessionId: createdSession.id, + messages: [], + rawMessages: [], + rawSdkEventPayloads: [], + }); this.view?.webview.postMessage({ type: "subagentSnapshot", ...this.subagentTracker.getSnapshotPayload(), @@ -3405,27 +3426,32 @@ export class ChatViewProvider } const retryWithoutStructuredOutput = message.retryWithoutStructuredOutput === true; - // Reload chat history to show clean state before retry - try { - const rawMessages = await this.sessionService.getMessages( - retrySessionId, - ); - const messages = await this.processHistoryMessages( - rawMessages, - retrySessionId, - ); + // Reload chat history to show clean state before retry + try { + const rawMessages = await this.sessionService.getMessages( + retrySessionId, + ); + const rawSessionPayloads = await this.loadSessionDebugPayloads( + retrySessionId, + ); + const messages = await this.processHistoryMessages( + rawMessages, + retrySessionId, + ); this.logHistoryRenderDiagnostics( "retryLastMessage.reload", retrySessionId, rawMessages, messages, ); - this.view?.webview.postMessage({ - type: "chatHistory", - sessionId: retrySessionId, - messages: messages, - processingSessionIds: this.getEffectiveProcessingSessionIds(), - }); + this.view?.webview.postMessage({ + type: "chatHistory", + sessionId: retrySessionId, + messages: messages, + rawMessages: rawSessionPayloads.rawMessages, + rawSdkEventPayloads: rawSessionPayloads.rawSdkEventPayloads, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + }); } catch (err) { this.logger.error("Failed to load messages for retry", { err }); } @@ -3567,7 +3593,7 @@ export class ChatViewProvider }); // Subscribe to stream events - this.unsubscribe = this.streamService.subscribe(async (event) => { + this.unsubscribe = this.streamService.subscribe(async (event, rawEvent) => { // Log stream events for debugging const eventRec = event as Record; const eventType = eventRec?.type || "unknown"; @@ -3582,7 +3608,7 @@ export class ChatViewProvider if (eventType === "message.updated" || isTerminalLifecycleEvent) { const props = (eventRec?.properties as Record | undefined) || {}; const info = (props?.info as Record | undefined) || {}; - this.logger.info("[OPENCOD GO MODEL] Stream lifecycle event", { + this.logger.debug("[OPENCOD GO MODEL] Stream lifecycle event", { eventType, sessionId: streamEventSessionId, providerID: info?.providerID, @@ -3803,20 +3829,61 @@ export class ChatViewProvider // Only forward stream events that belong to the currently active session. // Events from other sessions (e.g. a session that is still streaming after // the user switched away) must not leak into the current webview surface. - if ( - resolvedSessionId && - this.currentSessionId && - resolvedSessionId !== this.currentSessionId - ) { - return; - } - - this.view?.webview.postMessage({ - type: "streamEvent", - event: { ...enrichedEvent, sessionId: resolvedSessionId }, - }); - if (hasBlockingInteractive && resolvedSessionId) { - this.processingSessionIds.delete(resolvedSessionId); + if ( + resolvedSessionId && + this.currentSessionId && + resolvedSessionId !== this.currentSessionId + ) { + return; + } + + const properties = (eventRec?.properties as Record | undefined) || {}; + const part = (properties?.part as Record | undefined) || {}; + const info = (properties?.info as Record | undefined) || {}; + const preRenderPreview = + (typeof part?.delta === "string" && part.delta) || + (typeof part?.text === "string" && part.text) || + (typeof part?.content === "string" && part.content) || + (typeof properties?.delta === "string" && properties.delta) || + (typeof properties?.text === "string" && properties.text) || + (typeof properties?.content === "string" && properties.content) || + undefined; + + this.logger.info("[CHAT-STREAMING][KEY2] Forwarding stream event to webview", { + eventType: event.type || "unknown", + sessionId: resolvedSessionId, + activeStreamSessionId: this.activeStreamSessionId, + currentSessionId: this.currentSessionId, + messageId: + typeof info?.id === "string" + ? info.id + : typeof properties?.messageId === "string" + ? properties.messageId + : typeof properties?.messageID === "string" + ? properties.messageID + : undefined, + partType: typeof part?.type === "string" ? part.type : undefined, + hasStructuredOutput: Boolean((enrichedEvent as any)?.structuredOutput), + preview: + typeof preRenderPreview === "string" + ? preRenderPreview.slice(0, 160) + : undefined, + }); + + this.view?.webview.postMessage({ + type: "streamEvent", + event: { ...enrichedEvent, sessionId: resolvedSessionId }, + }); + if (resolvedSessionId) { + void this.sessionService.appendRawSdkEventPayload( + resolvedSessionId, + rawEvent + ? { ...(rawEvent as Record), sessionId: resolvedSessionId } + : { ...enrichedEvent, sessionId: resolvedSessionId }, + ); + } + if (hasBlockingInteractive && resolvedSessionId) { + this.processingSessionIds.delete(resolvedSessionId); if (this.activeStreamSessionId === resolvedSessionId) { this.activeStreamSessionId = undefined; } @@ -3987,7 +4054,7 @@ export class ChatViewProvider * Processes raw history messages by applying structured outputs and enriching * plan metadata for rendering. */ - private async processHistoryMessages(messages: any[], sessionId: string): Promise { + private async processHistoryMessages(messages: any[], sessionId: string): Promise { // Delegate to HistoryProcessor for canonical message processing if (!this.historyProcessor) { this.logger.warn("HistoryProcessor not initialized, returning empty messages"); @@ -4014,11 +4081,34 @@ export class ChatViewProvider stack: error instanceof Error ? error.stack : undefined, }); // Return original messages as fallback - return messages; - } - } - - private isAssistantHistoryMessage(message: any): boolean { + return messages; + } + } + + /** + * Loads the raw session payloads that drive the centralized debug tape. + * + * NOTE: TO BE REMOVED when the conversation UI no longer needs separate + * extension-host hydration of raw event payloads. + */ + private async loadSessionDebugPayloads(sessionId: string): Promise<{ + rawMessages: unknown[]; + rawSdkEventPayloads: unknown[]; + }> { + const [rawMessages, rawSdkEventPayloads] = await Promise.all([ + this.sessionService.loadSessionRawMessages(sessionId), + this.sessionService.loadSessionRawSdkEventPayloads(sessionId), + ]); + + return { + rawMessages: Array.isArray(rawMessages) ? rawMessages : [], + rawSdkEventPayloads: Array.isArray(rawSdkEventPayloads) + ? rawSdkEventPayloads + : [], + }; + } + + private isAssistantHistoryMessage(message: any): boolean { return ( this.firstNonEmptyString(message?.role, message?.info?.role)?.toLowerCase() === "assistant" @@ -4317,23 +4407,16 @@ export class ChatViewProvider sessionId: string, baselineAssistantMarker?: AssistantHistoryMarker, failureMessage?: string, - ): Promise { - const pollDelaysMs = this.getTimeoutRecoveryPollDelays(failureMessage); - const promptAwaitHang = this.isPromptAwaitHangError( - this.firstNonEmptyString(failureMessage) || "", - ); - const timeoutLikeFailure = this.isLikelyInteractiveAwaitTimeoutError( - this.firstNonEmptyString(failureMessage) || "", - ); - const startedAt = Date.now(); - const maxWaitMs = promptAwaitHang - ? 15 * 1000 - : timeoutLikeFailure - ? 30 * 60 * 1000 - : 60 * 1000; - this.logger.info("Attempting timeout recovery from session history", { - sessionId, - timeoutLikeFailure, + ): Promise { + const pollDelaysMs = this.getTimeoutRecoveryPollDelays(failureMessage); + const timeoutLikeFailure = this.isLikelyInteractiveAwaitTimeoutError( + this.firstNonEmptyString(failureMessage) || "", + ); + const startedAt = Date.now(); + const maxWaitMs = 60_000; + this.logger.info("Attempting timeout recovery from session history", { + sessionId, + timeoutLikeFailure, pollAttempts: pollDelaysMs.length, maxWaitMs, }); @@ -4354,31 +4437,36 @@ export class ChatViewProvider }); } - if (Array.isArray(rawMessages)) { - const latestAssistantMarker = - this.getLatestAssistantHistoryMarker(rawMessages); - if ( - this.hasAssistantHistoryAdvanced( - latestAssistantMarker, - baselineAssistantMarker, - ) - ) { - const processedMessages = await this.processHistoryMessages( - rawMessages, - sessionId, - ); + if (Array.isArray(rawMessages)) { + const latestAssistantMarker = + this.getLatestAssistantHistoryMarker(rawMessages); + if ( + this.hasAssistantHistoryAdvanced( + latestAssistantMarker, + baselineAssistantMarker, + ) + ) { + const rawSessionPayloads = await this.loadSessionDebugPayloads( + sessionId, + ); + const processedMessages = await this.processHistoryMessages( + rawMessages, + sessionId, + ); this.logHistoryRenderDiagnostics( "timeout-recovery", sessionId, rawMessages, processedMessages, ); - this.view?.webview.postMessage({ - type: "chatHistory", - sessionId, - messages: processedMessages, - processingSessionIds: this.getEffectiveProcessingSessionIds(), - }); + this.view?.webview.postMessage({ + type: "chatHistory", + sessionId, + messages: processedMessages, + rawMessages: rawSessionPayloads.rawMessages, + rawSdkEventPayloads: rawSessionPayloads.rawSdkEventPayloads, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + }); this.logger.info("Timeout recovery succeeded from session history", { sessionId, attempt: attemptIndex + 1, @@ -4584,15 +4672,15 @@ export class ChatViewProvider return lines.join("\n"); } - private getRequestTimeout(): number { - const config = vscode.workspace.getConfiguration('opencode'); - const timeout = config.get('requestTimeout', 120000); - if (timeout < 10000 || timeout > 600000) { - this.logger.warn(`Invalid requestTimeout configured: ${timeout}ms, using default 120000ms`); - return 120000; - } - return timeout; - } + private getRequestTimeout(): number { + const config = vscode.workspace.getConfiguration('opencode'); + const timeout = config.get('requestTimeout', 60_000); + if (timeout < 10000 || timeout > 600000) { + this.logger.warn(`Invalid requestTimeout configured: ${timeout}ms, using default 60000ms`); + return 60_000; + } + return timeout; + } private calculateTimeoutForQuery( baseTimeout: number, @@ -5657,11 +5745,11 @@ export class ChatViewProvider return undefined; } - private extractMessageBodyText(message: any): string { - if (!message) return ""; - - let rawText = ""; - if (Array.isArray(message.parts)) { + private extractMessageBodyText(message: any): string { + if (!message) return ""; + + let rawText = ""; + if (Array.isArray(message.parts)) { rawText = message.parts .map((part: any) => { if (!part || typeof part !== "object") return ""; @@ -5670,13 +5758,47 @@ export class ChatViewProvider } return (part.text || part.content || part.message || "").toString(); }) - .join(" ") - .trim(); - } - - if (!rawText && typeof message.structuredOutput?.message === "string") { - rawText = message.structuredOutput.message.trim(); - } + .join(" ") + .trim(); + } + + if (!rawText && message?.rawResponse) { + const rawResponseRec = (() => { + const direct = this.asRecord(message.rawResponse); + if (direct) { + return direct; + } + if (typeof message.rawResponse !== "string") { + return undefined; + } + const trimmed = message.rawResponse.trim(); + if (!trimmed) { + return undefined; + } + try { + return this.asRecord(JSON.parse(trimmed)); + } catch { + return undefined; + } + })(); + const rawResponseParts = Array.isArray(rawResponseRec?.parts) + ? rawResponseRec.parts + : []; + rawText = rawResponseParts + .map((part: any) => { + if (!part || typeof part !== "object") return ""; + if (!this.isRenderableTextPart(part)) { + return ""; + } + return (part.text || part.content || part.message || "").toString(); + }) + .join(" ") + .trim(); + } + + if (!rawText && typeof message.structuredOutput?.message === "string") { + rawText = message.structuredOutput.message.trim(); + } if (!rawText && typeof message.content === "string" && message.content.trim()) { rawText = message.content.trim(); @@ -9381,14 +9503,19 @@ export class ChatViewProvider /** * Sends the current queue state to the webview */ - public dispose(): void { - if (this.unsubscribe) { - this.unsubscribe(); - this.unsubscribe = undefined; - } - this.streamService.dispose(); - this.quotaService.dispose(); - this.fileThemeProcessor.unsubscribe(this); + public dispose(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = undefined; + } + if (this.quotaServiceListener) { + this.quotaServiceListener.dispose(); + this.quotaServiceListener = undefined; + } + this.streamService.dispose(); + this.quotaService.dispose(); + void this.sessionService.dispose(); + this.fileThemeProcessor.unsubscribe(this); this.isBootstrappingWebview = false; this.hasInitializedWebview = false; this.sessionsListRequestVersion = 0; diff --git a/src/providers/SkillsPanelProvider.ts b/src/providers/SkillsPanelProvider.ts index b33e606..6a622cc 100644 --- a/src/providers/SkillsPanelProvider.ts +++ b/src/providers/SkillsPanelProvider.ts @@ -11,6 +11,7 @@ export class SkillsPanelProvider { private _view?: vscode.WebviewView; private _disposables: vscode.Disposable[] = []; + private _serviceListenersRegistered = false; private logger = createLogger(LoggingCategories.SKILL_SERVICE); constructor( @@ -44,18 +45,7 @@ export class SkillsPanelProvider { this._disposables ); - // Listen for skill changes - this.skillManagementService.onDidChangeSkills(() => { - this.logger.info('[SkillsPanel] onDidChangeSkills fired'); - this._sendSkillsToWebview(); - }); - - // Listen for initialization completion - // This handles the case where the webview is opened before initialization completes - this.skillManagementService.onInitialized(() => { - this.logger.info('[SkillsPanel] SkillManagementService initialized, sending skills to webview'); - this._sendSkillsToWebview(); - }); + this.registerServiceListeners(); // Send initial data after a short delay to ensure webview is ready setTimeout(() => { @@ -183,6 +173,26 @@ export class SkillsPanelProvider { }); } + private registerServiceListeners(): void { + if (this._serviceListenersRegistered) { + return; + } + + this._serviceListenersRegistered = true; + + this._disposables.push( + this.skillManagementService.onDidChangeSkills(() => { + this.logger.info('[SkillsPanel] onDidChangeSkills fired'); + this._sendSkillsToWebview(); + }), + this.skillManagementService.onInitialized(() => { + // Handles the case where the webview opens before initialization completes. + this.logger.info('[SkillsPanel] SkillManagementService initialized, sending skills to webview'); + this._sendSkillsToWebview(); + }), + ); + } + private _getHtmlForWebview(webview: vscode.Webview): string { const nonce = getNonce(); @@ -229,6 +239,7 @@ export class SkillsPanelProvider { public dispose(): void { SkillsPanelProvider.currentPanel = undefined; + this._serviceListenersRegistered = false; while (this._disposables.length) { const disposable = this._disposables.pop(); diff --git a/src/providers/chat/HistoryProcessor.ts b/src/providers/chat/HistoryProcessor.ts index 474a851..5317d8d 100644 --- a/src/providers/chat/HistoryProcessor.ts +++ b/src/providers/chat/HistoryProcessor.ts @@ -118,6 +118,7 @@ export class HistoryProcessor { ...structuredApplied, content: originalBodyText, text: originalBodyText, + rawSdkEventPayloads: structuredApplied?.rawSdkEventPayloads, structuredOutput: structuredApplied?.structuredOutput && this.firstNonEmptyString(structuredApplied.structuredOutput.responseType) @@ -568,6 +569,11 @@ export class HistoryProcessor { : []; base.steps = Array.isArray(base.steps) ? [...base.steps] : []; base.reasoning = Array.isArray(base.reasoning) ? [...base.reasoning] : []; + let latestRawSdkEventPayloads: unknown[] | undefined = Array.isArray( + base.rawSdkEventPayloads, + ) + ? [...base.rawSdkEventPayloads] + : undefined; base.info = mergeInfoRecord(base.info, undefined); let latestRawResponse: unknown = base.rawResponse; @@ -637,9 +643,20 @@ export class HistoryProcessor { if (message && "rawResponse" in message) { latestRawResponse = message.rawResponse; } + if (Array.isArray(message?.rawSdkEventPayloads)) { + const rawSdkEventPayloads = message.rawSdkEventPayloads as unknown[]; + if (rawSdkEventPayloads.length > 0) { + latestRawSdkEventPayloads = [...rawSdkEventPayloads]; + } + } } base.rawResponse = latestRawResponse; + if (Array.isArray(latestRawSdkEventPayloads) && latestRawSdkEventPayloads.length > 0) { + base.rawSdkEventPayloads = latestRawSdkEventPayloads; + } else { + delete base.rawSdkEventPayloads; + } base.content = visibleBodyText || this.extractMessageBodyText(base); if (typeof base.text === "string" || visibleBodyText) { base.text = visibleBodyText || this.firstNonEmptyString(base.text); diff --git a/src/providers/chat/ModelAndAgentManager.ts b/src/providers/chat/ModelAndAgentManager.ts index 7716c11..3591170 100644 --- a/src/providers/chat/ModelAndAgentManager.ts +++ b/src/providers/chat/ModelAndAgentManager.ts @@ -353,7 +353,7 @@ export class ModelAndAgentManager { try { const configResponse = await this.withTimeout( client.config.providers(), - 3000, + 60_000, "config.providers probe", ); const configData = this.asRecord(this.asRecord(configResponse)?.data); @@ -431,7 +431,7 @@ export class ModelAndAgentManager { this.logger.featureStep(correlationId, 'fetch-provider-list'); - const providerListTimeoutMs = 25000; + const providerListTimeoutMs = 60_000; const response = (await this.withTimeout( client.provider.list(), providerListTimeoutMs, diff --git a/src/providers/chat/SessionHandler.ts b/src/providers/chat/SessionHandler.ts index f78ef97..5021a5b 100644 --- a/src/providers/chat/SessionHandler.ts +++ b/src/providers/chat/SessionHandler.ts @@ -157,10 +157,15 @@ export class SessionHandler { // This updates the service's internal state and persists it await this.sessionService.switchSession(sessionId); - const rawMessages = await this.sessionService.loadSessionMessages(sessionId); - const messages = Array.isArray(rawMessages) - ? await this.historyProcessor.processHistoryMessages(rawMessages, sessionId) - : []; + const rawMessages = await this.sessionService.loadSessionRawMessages(sessionId); + const rawSdkEventPayloads = await this.sessionService.loadSessionRawSdkEventPayloads(sessionId); + const fallbackMessages = rawMessages.length > 0 + ? rawMessages + : await this.sessionService.loadSessionMessages(sessionId); + const messages = await this.historyProcessor.processHistoryMessages( + fallbackMessages, + sessionId, + ); await this.subagentPersistence.syncSubagentSnapshotForSession(sessionId, messages); await this.modelAndAgentManager.applySessionSettings(sessionId); @@ -169,6 +174,8 @@ export class SessionHandler { type: "chatHistory", sessionId, messages, + rawMessages: fallbackMessages, + rawSdkEventPayloads, }); await this.compactionManager.sendCompactionViewStateForMessages( sessionId, diff --git a/src/providers/chat/StreamEventHandler.ts b/src/providers/chat/StreamEventHandler.ts index 90207d3..6d5825f 100644 --- a/src/providers/chat/StreamEventHandler.ts +++ b/src/providers/chat/StreamEventHandler.ts @@ -12,6 +12,7 @@ import type { SubagentPersistence } from "./SubagentPersistence"; import type { CompactionManager } from "./CompactionManager"; import type { DiagnosticsLogger } from "./DiagnosticsLogger"; import type { GeminiTokenUsageTracker } from "../../services/GeminiTokenUsageTracker"; +import type { SessionService } from "../../services/SessionService"; import type { SubagentTracker } from "../../services/SubagentTracker"; import { LoggingCategories } from "../../utils/LoggingSchema"; @@ -27,6 +28,7 @@ export class StreamEventHandler { private compactionManager: CompactionManager, private diagnosticsLogger: DiagnosticsLogger, private geminiTokenTracker: GeminiTokenUsageTracker, + private sessionService: SessionService, private subagentTracker: SubagentTracker, private logger: ReturnType, ) { @@ -48,7 +50,7 @@ export class StreamEventHandler { /** * Handle stream event */ - async handleStreamEvent(event: any): Promise { + async handleStreamEvent(event: any, rawEvent?: unknown): Promise { if (!event) return; const eventType = typeof event?.type === "string" @@ -66,7 +68,7 @@ export class StreamEventHandler { eventType === "session.created"; if (shouldLog) { - this.logger.info("[SOURCE] stream event received", { + this.logger.debug("[SOURCE] stream event received", { sessionId: this.getCurrentSessionId(), eventType, eventKeys: event && typeof event === "object" ? Object.keys(event) : [], @@ -76,7 +78,7 @@ export class StreamEventHandler { if (eventType === "session.created") { const properties = enrichedEvent?.properties || event?.properties || {}; const info = properties?.info || {}; - this.logger.info('===SUBAGENT_SPAWN=== [SESSION_CREATED] Stream event received', { + this.logger.debug('===SUBAGENT_SPAWN=== [SESSION_CREATED] Stream event received', { hasInfo: Boolean(info && typeof info === 'object'), infoKeys: info ? Object.keys(info) : [], hasProviderID: Boolean(info?.providerID), @@ -112,30 +114,22 @@ export class StreamEventHandler { const subagentUpdate = properties?.subagentsDelta || enrichedEvent?.structured?.subagentsDelta; // Log the raw subagent data from the stream - this.logger.info('[SUBAGENT][STREAM] Raw subagent data received', { + this.logger.debug('[SUBAGENT][STREAM] Raw subagent data received', { hasSummaries: Boolean(subagentUpdate?.summariesByParentMessageId || subagentUpdate?.subagentsByParentMessageId), hasDetails: Boolean(subagentUpdate?.detailsById || subagentUpdate?.subagentDetailsById), - sampleData: subagentUpdate ? { - summariesKeys: Object.keys(subagentUpdate.summariesByParentMessageId || subagentUpdate.subagentsByParentMessageId || {}).slice(0, 2), - detailsKeys: Object.keys(subagentUpdate.detailsById || subagentUpdate.subagentDetailsById || {}).slice(0, 2), - sampleSummary: subagentUpdate?.summariesByParentMessageId?.[Object.keys(subagentUpdate.summariesByParentMessageId || {})[0]]?.[0] || - subagentUpdate?.subagentsByParentMessageId?.[Object.keys(subagentUpdate.subagentsByParentMessageId || {})[0]]?.[0], - sampleDetail: subagentUpdate?.detailsById?.[Object.keys(subagentUpdate.detailsById || {})[0]] || - subagentUpdate?.subagentDetailsById?.[Object.keys(subagentUpdate.subagentDetailsById || {})[0]], - } : null, }); // Log provider/model field presence in stream data if (subagentUpdate?.detailsById || subagentUpdate?.subagentDetailsById) { const detailsById = subagentUpdate.detailsById || subagentUpdate.subagentDetailsById; const detailIds = Object.keys(detailsById); - this.logger.info('===SUBAGENT_SPAWN=== [STREAM] Subagent update received', { + this.logger.debug('===SUBAGENT_SPAWN=== [STREAM] Subagent update received', { detailCount: detailIds.length, detailIds: detailIds, }); for (const detailId of detailIds.slice(0, 3)) { const detail = detailsById[detailId]; - this.logger.info('===SUBAGENT_SPAWN=== [STREAM] Detail data', { + this.logger.debug('===SUBAGENT_SPAWN=== [STREAM] Detail data', { detailId, hasProviderID: Boolean(detail?.providerID), hasModelID: Boolean(detail?.modelID), @@ -166,7 +160,7 @@ export class StreamEventHandler { // Forward to webview if (shouldLog) { - this.logger.info("[PRE-RENDER] stream event forwarded", { + this.logger.debug("[PRE-RENDER] stream event forwarded", { sessionId, eventType, hasStructuredOutput: Boolean(enrichedEvent?.structuredOutput), @@ -179,6 +173,13 @@ export class StreamEventHandler { event: enrichedEvent || event, sessionId, }); + + if (sessionId) { + void this.sessionService.appendRawSdkEventPayload( + sessionId, + rawEvent ? { ...(rawEvent as Record), sessionId } : { ...event, sessionId }, + ); + } } /** @@ -194,7 +195,7 @@ export class StreamEventHandler { messageId, }); - this.logger.info( 'AI stream started', { + this.logger.debug( 'AI stream started', { correlationId, sessionId, messageId, @@ -232,7 +233,7 @@ export class StreamEventHandler { }); } - this.logger.info( 'AI stream ended', { + this.logger.debug( 'AI stream ended', { sessionId, messageId, duration, @@ -240,6 +241,8 @@ export class StreamEventHandler { success, }); + void this.sessionService.flushRawSdkEventPayloads(sessionId); + // Reset state this.streamStartTime = undefined; this.eventCount = 0; @@ -254,7 +257,7 @@ export class StreamEventHandler { messageId: string, structured: any, ): void { - this.logger.info( 'Structured output processed', { + this.logger.debug( 'Structured output processed', { sessionId, messageId, responseType: structured.responseType, diff --git a/src/services/.gitkeep b/src/services/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/services/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/services/MessageStreamService.ts b/src/services/MessageStreamService.ts index bdc37c2..c5ed9f7 100644 --- a/src/services/MessageStreamService.ts +++ b/src/services/MessageStreamService.ts @@ -101,7 +101,7 @@ export interface StreamEvent { * }; * ``` */ -export type StreamCallback = (event: StreamEvent) => void; +export type StreamCallback = (event: StreamEvent, rawEvent?: unknown) => void; /** * Manages real-time Server-Sent Events streaming from the OpenCode server. @@ -157,6 +157,9 @@ export class MessageStreamService { /** Structured logger */ private logger = createLogger(LoggingCategories.STREAM_HANDLER); + /** Prefer unscoped stream subscriptions after a transport failure. */ + private preferUnscopedStreamSubscription = false; + /** * Dedupes mirrored events when both /event and /global/event are active. * Stores source metadata so we only collapse cross-stream mirrors and keep @@ -191,6 +194,63 @@ export class MessageStreamService { return `${value.slice(0, max)}...`; } + private cloneRawEvent(value: T): T { + if (typeof structuredClone === "function") { + try { + return structuredClone(value); + } catch { + return value; + } + } + return value; + } + + private isLikelyStreamTransportFailure(error: unknown): boolean { + const normalized = + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); + const lower = normalized.toLowerCase(); + return ( + lower.includes("fetch failed") || + lower.includes("timeout") || + lower.includes("headers timeout") || + lower.includes("body timeout") || + lower.includes("response timeout") + ); + } + + private scheduleStreamReconnect(reason: string, error?: unknown): void { + if (this.reconnectTimer || this.callbacks.size === 0) { + return; + } + + if (error && this.isLikelyStreamTransportFailure(error)) { + this.preferUnscopedStreamSubscription = true; + } + + this.logger.warn("SSE transport failure detected; scheduling reconnect", { + reason, + preferUnscopedStreamSubscription: this.preferUnscopedStreamSubscription, + error: error instanceof Error ? error.message : error ? String(error) : undefined, + }); + + this.stopListening(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.callbacks.size === 0) { + return; + } + this.logger.connectionEvent("reconnecting", { + subscriberCount: this.callbacks.size, + preferUnscopedStreamSubscription: this.preferUnscopedStreamSubscription, + }); + this.startListening().catch((err) => { + this.logger.error("Auto-reconnect failed", {}, err as Error); + }); + }, 1000); + } + private extractEventTypeHints(rawEvent: unknown): string[] { const hints = new Set(); const seen = new WeakSet(); @@ -314,7 +374,9 @@ export class MessageStreamService { workspaceDirectory, }); } - const eventSubscribeOptions = workspaceDirectory + const useScopedEventSubscription = + !!workspaceDirectory && !this.preferUnscopedStreamSubscription; + const eventSubscribeOptions = useScopedEventSubscription ? { query: { directory: workspaceDirectory }, onSseEvent: (sseEvent: unknown) => { @@ -339,6 +401,7 @@ export class MessageStreamService { }, onSseError: (error: unknown) => { this.logger.error("/event SSE callback error", {}, error as Error); + this.scheduleStreamReconnect("/event", error); }, } : { @@ -364,45 +427,48 @@ export class MessageStreamService { }, onSseError: (error: unknown) => { this.logger.error("/event SSE callback error", {}, error as Error); + this.scheduleStreamReconnect("/event", error); }, }; this.logger.info("Subscribing to /event", { directory: workspaceDirectory, + scoped: useScopedEventSubscription, }); let events; try { events = await client.event.subscribe(eventSubscribeOptions); } catch (subscribeError) { - // if (!workspaceDirectory) { - // throw subscribeError; - // } - // console.warn( - // "[MessageStreamService] Scoped /event subscription failed, retrying without directory query:", - // subscribeError, - // ); - // events = await client.event.subscribe({ - // onSseEvent: (sseEvent: unknown) => { - // const rec = this.asRecord(sseEvent); - // const data = rec?.data; - // const eventHints = this.extractEventTypeHints(data); - // const eventType = eventHints[0]; - // if (!this.isHeartbeatEvent(eventType)) { - // console.log("[MessageStreamService] /event SSE frame", { - // eventType: eventType || "unknown", - // eventName: typeof rec?.event === "string" ? rec.event : undefined, - // lastEventId: typeof rec?.id === "string" ? rec.id : undefined, - // preview: this.asPreview( - // typeof data === "string" - // ? data - // : JSON.stringify(this.sanitizeForLogging(data)), - // ), - // }); - // } - // }, - // onSseError: (error: unknown) => { - // console.error("[MessageStreamService] /event SSE callback error:", error); - // }, - // }); + if (!workspaceDirectory) { + throw subscribeError; + } + this.logger.warn("Scoped /event subscription failed; retrying without directory query", { + workspaceDirectory, + error: subscribeError instanceof Error ? subscribeError.message : String(subscribeError), + }); + events = await client.event.subscribe({ + onSseEvent: (sseEvent: unknown) => { + const rec = this.asRecord(sseEvent); + const data = rec?.data; + const eventHints = this.extractEventTypeHints(data); + const eventType = eventHints[0]; + if (!this.isHeartbeatEvent(eventType) && this.shouldVerboseStreamDebug()) { + this.logger.debug("/event SSE frame", { + eventType: eventType || "unknown", + eventName: typeof rec?.event === "string" ? rec.event : undefined, + lastEventId: typeof rec?.id === "string" ? rec.id : undefined, + preview: this.asPreview( + typeof data === "string" + ? data + : JSON.stringify(this.sanitizeForLogging(data)), + ), + }); + } + }, + onSseError: (error: unknown) => { + this.logger.error("/event SSE callback error", {}, error as Error); + this.scheduleStreamReconnect("/event", error); + }, + }); } this.logger.performance("Connection established", Date.now() - startTime, { @@ -450,6 +516,7 @@ export class MessageStreamService { }, onSseError: (error: unknown) => { this.logger.error("/global/event SSE callback error", {}, error as Error); + this.scheduleStreamReconnect("/global/event", error); }, }); streamTasks.push( @@ -569,7 +636,8 @@ export class MessageStreamService { } try { - const normalizedEvent = this.normalizeIncomingEvent(rawEvent); + const rawEventSnapshot = this.cloneRawEvent(rawEvent); + const normalizedEvent = this.normalizeIncomingEvent(rawEventSnapshot); if (!normalizedEvent) { const eventTypeHints = this.extractEventTypeHints(rawEvent); this.logger.warn("Skipping unknown event shape", { @@ -582,10 +650,48 @@ export class MessageStreamService { continue; } + const properties = this.asRecord(normalizedEvent.properties) ?? {}; + const part = this.asRecord(properties.part); + const info = this.asRecord(properties.info); + const sessionId = + (typeof properties.sessionID === "string" && properties.sessionID) || + (typeof properties.sessionId === "string" && properties.sessionId) || + (typeof part?.sessionID === "string" && part.sessionID) || + (typeof part?.sessionId === "string" && part.sessionId) || + (typeof info?.sessionID === "string" && info.sessionID) || + (typeof info?.sessionId === "string" && info.sessionId) || + undefined; + const messageId = + (typeof properties.messageID === "string" && properties.messageID) || + (typeof properties.messageId === "string" && properties.messageId) || + (typeof part?.messageID === "string" && part.messageID) || + (typeof part?.messageId === "string" && part.messageId) || + (typeof info?.id === "string" && info.id) || + undefined; + const partType = + typeof part?.type === "string" ? part.type : undefined; + const preview = this.asPreview( + (typeof part?.delta === "string" && part.delta) || + (typeof part?.text === "string" && part.text) || + (typeof part?.content === "string" && part.content) || + (typeof properties.delta === "string" && properties.delta) || + (typeof properties.text === "string" && properties.text) || + (typeof properties.content === "string" && properties.content) || + undefined, + ); + + if (!this.isHeartbeatEvent(normalizedEvent.type) && verboseDebug) { + this.logger.debug("[CHAT-STREAMING][KEY1] Stream event received from server", { + source, + type: normalizedEvent.type, + sessionId, + messageId, + partType, + preview, + }); + } + if (!this.isHeartbeatEvent(normalizedEvent.type) && verboseDebug) { - const properties = this.asRecord(normalizedEvent.properties); - const part = this.asRecord(properties?.part); - const info = this.asRecord(properties?.info); this.logger.debug("Incoming stream event", { source, type: normalizedEvent.type, @@ -652,7 +758,7 @@ export class MessageStreamService { continue; } - this.notifyCallbacks(eventWithSource); + this.notifyCallbacks(eventWithSource, rawEventSnapshot); } catch (error) { this.logger.error("Failed to process event", { source }, error as Error); } @@ -965,7 +1071,7 @@ export class MessageStreamService { return normalizeSdkStreamEvent(rawEvent) as StreamEvent | null; } - private notifyCallbacks(event: StreamEvent): void { + private notifyCallbacks(event: StreamEvent, rawEvent?: unknown): void { if (this.shouldVerboseStreamDebug()) { // Log all stream event properties for debugging const sanitizedProperties = this.sanitizeForLogging(event.properties); @@ -1108,7 +1214,7 @@ export class MessageStreamService { this.callbacks.forEach((callback) => { try { - callback(event); + callback(event, rawEvent); } catch (error) { // Log but don't throw - one bad callback shouldn't break everything this.logger.error("Callback error in subscriber", {}, error as Error); diff --git a/src/services/ModelCapabilitiesService.ts b/src/services/ModelCapabilitiesService.ts index b926206..beae441 100644 --- a/src/services/ModelCapabilitiesService.ts +++ b/src/services/ModelCapabilitiesService.ts @@ -9,7 +9,7 @@ export interface ModelCapability { } const CACHE_TTL_MS = 60_000; -const FETCH_TIMEOUT_MS = 5_000; +const FETCH_TIMEOUT_MS = 60_000; const MODELS_DEV_URL = "https://models.dev/api.json"; export class ModelCapabilitiesService { diff --git a/src/services/OpencodeServerManager.ts b/src/services/OpencodeServerManager.ts index 3ebb623..49a1c57 100644 --- a/src/services/OpencodeServerManager.ts +++ b/src/services/OpencodeServerManager.ts @@ -82,6 +82,7 @@ export type ServerStatus = "idle" | "starting" | "running" | "error"; const SERVER_OUTPUT_LOG_BUDGET_CHARS = 16_384; const SERVER_OUTPUT_RECENT_BUFFER_CHARS = 8_192; const MANAGED_PORT_STATE_KEY = "opencode.server.lastManagedPort"; +const LOOPBACK_HOST = "127.0.0.1"; /** * Manages the OpenCode CLI server lifecycle and connection. @@ -609,7 +610,7 @@ export class OpencodeServerManager { * - Server readiness detection via stdout parsing * - Error handling for missing CLI * - Auto-reconnect on unexpected exit - * - Startup timeout (10 seconds) + * - Startup timeout (60 seconds) * ** Algorithm:** * 1. Find available port using `findAvailablePort()` @@ -617,7 +618,7 @@ export class OpencodeServerManager { * 3. Set up event listeners for stdout, stderr, error, and exit * 4. Wait for "Server running" or "listening" in stdout * 5. Create SDK client and resolve promise - * 6. Handle timeout after 10 seconds + * 6. Handle timeout after 60 seconds * ** Working Directory:** * - If workspace folder exists: Sets CWD to workspace root @@ -852,8 +853,8 @@ export class OpencodeServerManager { } }); - // Step 6: Timeout after 10 seconds - // If server doesn't become ready within 10 seconds, fail fast + // Step 6: Timeout after 60 seconds + // If server doesn't become ready within 60 seconds, fail fast startupTimeout = setTimeout(() => { if (!serverReady) { const recentTail = recentServerOutput.trim().slice(-800); @@ -861,7 +862,7 @@ export class OpencodeServerManager { this.setStatus("error", recentTail ? `Server startup timeout${details}` : undefined); settleReject(new Error(`Server startup timeout.${details}`)); } - }, 10000); + }, 60_000); }); } @@ -897,7 +898,8 @@ export class OpencodeServerManager { }); } this.client = createOpencodeClient({ - baseUrl: `http://localhost:${this.port}`, + // Keep the SDK on the same loopback host used by connectivity checks. + baseUrl: `http://${LOOPBACK_HOST}:${this.port}`, directory: workspaceDirectory, }); @@ -1318,7 +1320,7 @@ export class OpencodeServerManager { socket.once("timeout", () => finish(false)); try { - socket.connect(port, "127.0.0.1"); + socket.connect(port, LOOPBACK_HOST); } catch { finish(false); } diff --git a/src/services/QuotaService.ts b/src/services/QuotaService.ts index f021293..fd2690d 100644 --- a/src/services/QuotaService.ts +++ b/src/services/QuotaService.ts @@ -22,7 +22,7 @@ import type { // ── Constants ────────────────────────────────────────────────────────────────── const DEFAULT_REFRESH_INTERVAL = 5 * 60 * 1000; -const REQUEST_TIMEOUT_MS = 10_000; +const REQUEST_TIMEOUT_MS = 60_000; const USER_AGENT = "vscode-opencode-quota-monitor/1.0"; const COPILOT_VERSION = "0.35.0"; const COPILOT_EDITOR_VERSION = "vscode/1.107.0"; diff --git a/src/services/SessionService.ts b/src/services/SessionService.ts index 14ccbe7..9b8f907 100644 --- a/src/services/SessionService.ts +++ b/src/services/SessionService.ts @@ -145,6 +145,24 @@ function sanitizeForPersistence( if (Object.keys(obj).length > entries.length) { result.__truncatedKeys = Object.keys(obj).length - entries.length; } + if (!Object.prototype.hasOwnProperty.call(result, "rawResponse") && + Object.prototype.hasOwnProperty.call(obj, "rawResponse")) { + result.rawResponse = sanitizeForPersistence( + obj.rawResponse, + depth + 1, + seen, + ); + } + if ( + !Object.prototype.hasOwnProperty.call(result, "rawSdkEventPayloads") && + Object.prototype.hasOwnProperty.call(obj, "rawSdkEventPayloads") + ) { + result.rawSdkEventPayloads = sanitizeForPersistence( + obj.rawSdkEventPayloads, + depth + 1, + seen, + ); + } return result; } @@ -153,7 +171,7 @@ function sanitizeForPersistence( function estimateSerializedBytes(value: unknown): number { try { - return Buffer.byteLength(JSON.stringify(value), "utf8"); + return Buffer.RbyteLength(JSON.stringify(value), "utf8"); } catch { return Number.MAX_SAFE_INTEGER; } @@ -327,6 +345,14 @@ function compactMessageForPersistence(message: unknown): unknown { .map((part) => sanitizeForPersistence(part)); } + if (Array.isArray(rec.rawSdkEventPayloads)) { + // Preserve the raw SDK event tape so rehydrated debug views can inspect + // the exact event payloads that drove the assistant turn. + compact.rawSdkEventPayloads = (rec.rawSdkEventPayloads as unknown[]) + .slice(-200) + .map((item) => sanitizeForPersistence(item)); + } + if (Array.isArray(rec.reasoningEvents)) { compact.reasoningEvents = (rec.reasoningEvents as unknown[]) .slice(-MAX_COMPACT_REASONING_EVENTS) @@ -784,6 +810,7 @@ function mergeRicherMessageFields( backfillArrayField("steps"); backfillArrayField("edits"); backfillArrayField("interactiveEvents"); + backfillArrayField("rawSdkEventPayloads"); backfillArrayField("parts"); backfillArrayField("subagents", true); backfillArrayField("images"); @@ -795,6 +822,12 @@ function mergeRicherMessageFields( if (!merged.structuredOutput && fallbackRec.structuredOutput) { merged.structuredOutput = fallbackRec.structuredOutput; } + if (!merged.rawResponse && fallbackRec.rawResponse) { + merged.rawResponse = fallbackRec.rawResponse; + } + if (!merged.rawSdkEventPayloads && fallbackRec.rawSdkEventPayloads) { + merged.rawSdkEventPayloads = fallbackRec.rawSdkEventPayloads; + } if (!merged.info && fallbackRec.info) { merged.info = fallbackRec.info; } @@ -1038,8 +1071,46 @@ export class SessionService { /** Promise that resolves when initialization completes */ private initializationPromise: Promise | null = null; - /** Track last logged session ID for deduplication */ - private lastLoggedSessionId: string | null = null; + /** Track last logged session ID for deduplication */ + private lastLoggedSessionId: string | null = null; + + /** In-memory cache of raw SDK event payloads by session for atomic appends */ + private rawSdkEventPayloadCache = new Map(); + + /** In-memory cache of raw SDK message payloads by session */ + private rawMessageCache = 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 shouldPersistRawSdkEventPayload(event: unknown): boolean { + if (!event || typeof event !== "object") { + return true; + } + const record = event as Record; + // Temporary debug alignment: keep the persisted tape on the same + // session-scoped shape the centralized panel shows while we test the + // rehydration contract. + return record.source !== "/global/event"; + } + + private filterPersistedRawSdkEventPayloads(events: unknown[] | undefined): unknown[] { + if (!Array.isArray(events) || events.length === 0) { + return []; + } + return events.filter((event) => this.shouldPersistRawSdkEventPayload(event)); + } // ============================================================================ // PERSISTENCE KEYS @@ -1053,6 +1124,13 @@ export class SessionService { /** Prefix for storing messages per session (appended with session ID) */ private static readonly MESSAGES_PREFIX = "opencode.session.messages."; + /** Prefix for storing raw SDK payloads per session (appended with session ID) */ + private static readonly RAW_MESSAGES_PREFIX = "opencode.session.raw-messages."; + + /** 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."; + /** Key for storing current session ID */ private static readonly SESSION_ID_KEY = "opencode.currentSessionId"; @@ -1364,12 +1442,16 @@ export class SessionService { * } * ``` */ - async switchSession(sessionId: string): Promise { - const flow = log.startFeatureFlow('SwitchSession', { sessionId }); - - try { - const client = await this.serverManager.ensureRunning(); - log.featureStep(flow, 'fetching_session_from_server'); + async switchSession(sessionId: string): Promise { + const flow = log.startFeatureFlow('SwitchSession', { sessionId }); + + try { + const currentSessionId = this.currentSession?.id; + if (currentSessionId && currentSessionId !== sessionId) { + await this.flushRawSdkEventPayloads(currentSessionId); + } + const client = await this.serverManager.ensureRunning(); + log.featureStep(flow, 'fetching_session_from_server'); const response = await client.session.get({ path: { id: sessionId }, @@ -1444,9 +1526,9 @@ export class SessionService { * // Session is now deleted from server and local cache * ``` */ - async deleteSession(sessionId: string): Promise { - const deleteStart = Date.now(); - const wasCurrent = this.currentSession?.id === sessionId; + async deleteSession(sessionId: string): Promise { + const deleteStart = Date.now(); + const wasCurrent = this.currentSession?.id === sessionId; try { const client = await this.serverManager.ensureRunning(); @@ -1460,14 +1542,15 @@ export class SessionService { }); } - if (wasCurrent) { - this.currentSession = null; - log.debug("Cleared current session (was deleted)", { sessionId }); - } - - this.sessionHistory = this.sessionHistory.filter((s) => s.id !== sessionId); - await this.context.workspaceState.update( - `${SessionService.MESSAGES_PREFIX}${sessionId}`, + if (wasCurrent) { + this.currentSession = null; + log.debug("Cleared current session (was deleted)", { sessionId }); + } + + await this.flushRawSdkEventPayloads(sessionId); + this.sessionHistory = this.sessionHistory.filter((s) => s.id !== sessionId); + await this.context.workspaceState.update( + `${SessionService.MESSAGES_PREFIX}${sessionId}`, undefined, ); this.persistState(); @@ -1764,6 +1847,24 @@ export class SessionService { ); } + /** + * Saves raw SDK payloads for a specific session to local workspace storage. + * + * This intentionally preserves the original payload shape as much as possible + * so debug and recovery flows can inspect the untouched SDK data. + */ + async saveSessionRawMessages( + sessionId: string, + messages: unknown[], + ): Promise { + const persisted = Array.isArray(messages) ? [...messages] : []; + this.rawMessageCache.set(sessionId, persisted); + await this.context.workspaceState.update( + `${SessionService.RAW_MESSAGES_PREFIX}${sessionId}`, + persisted, + ); + } + /** * Loads messages for a specific session from local storage. * @@ -1793,6 +1894,63 @@ export class SessionService { return Array.isArray(value) ? value : []; } + /** + * Loads raw SDK payloads for a specific session from local workspace storage. + */ + async loadSessionRawMessages(sessionId: string): Promise { + const cached = this.rawMessageCache.get(sessionId); + if (Array.isArray(cached)) { + return [...cached]; + } + const value = this.context.workspaceState.get( + `${SessionService.RAW_MESSAGES_PREFIX}${sessionId}`, + ); + const raw = Array.isArray(value) ? value : []; + this.rawMessageCache.set(sessionId, [...raw]); + return raw; + } + + /** + * Saves raw SDK event payloads for a specific session to local workspace storage. + * + * This stores the untouched event tape so rehydrated sessions can replay the + * same raw stream and append future events without normalization. + */ + async saveSessionRawSdkEventPayloads( + sessionId: string, + events: unknown[], + ): Promise { + const persisted = this.filterPersistedRawSdkEventPayloads(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, + ); + } + + /** + * Loads raw SDK event payloads for a specific session from local storage. + */ + async loadSessionRawSdkEventPayloads(sessionId: string): Promise { + const cached = this.rawSdkEventPayloadCache.get(sessionId); + if (Array.isArray(cached)) { + return [...cached]; + } + const value = this.context.workspaceState.get( + `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, + ); + const raw = this.filterPersistedRawSdkEventPayloads(Array.isArray(value) ? value : []); + this.rawSdkEventPayloadCache.set(sessionId, [...raw]); + return raw; + } + /** * Appends a new message to the local message history for a session. * @@ -1859,9 +2017,85 @@ export class SessionService { await this.saveSessionMessages(sessionId, messages); } - private async mergeMessagesForSessionAliases( - aliasesByCanonicalId: Map, - ): Promise { + async appendRawMessage(sessionId: string, message: unknown): Promise { + const messages = await this.loadSessionRawMessages(sessionId); + messages.push(message); + log.debug("Appending raw message to session", { + sessionId, + newTotal: messages.length, + role: (message as Record)?.role, + }); + await this.saveSessionRawMessages(sessionId, messages); + } + + async appendRawSdkEventPayload(sessionId: string, event: unknown): Promise { + if (!this.shouldPersistRawSdkEventPayload(event)) { + return; + } + const events = await this.loadSessionRawSdkEventPayloads(sessionId); + const snapshot = this.cloneRawSdkEventPayload(event); + const eventRecord = event as Record; + const eventId = typeof eventRecord?.id === "string" ? eventRecord.id : undefined; + if (eventId) { + const alreadyExists = events.some((existing) => { + const existingRecord = existing as Record; + return typeof existingRecord?.id === "string" && existingRecord.id === eventId; + }); + if (alreadyExists) { + return; + } + } + events.push(snapshot); + this.rawSdkEventPayloadCache.set(sessionId, events); + + const existingTimer = this.rawSdkEventPersistTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + this.rawSdkEventPersistTimers.set( + sessionId, + setTimeout(() => { + void this.flushRawSdkEventPayloads(sessionId); + this.rawSdkEventPersistTimers.delete(sessionId); + }, 250), + ); + } + + async flushRawSdkEventPayloads(sessionId: string): Promise { + const events = this.rawSdkEventPayloadCache.get(sessionId); + if (!Array.isArray(events)) { + return; + } + 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}`, + [...events], + ); + } + + async flushAllRawSdkEventPayloads(): Promise { + const sessionIds = Array.from(this.rawSdkEventPayloadCache.keys()); + for (const sessionId of sessionIds) { + await this.flushRawSdkEventPayloads(sessionId); + } + } + + async dispose(): Promise { + await this.flushAllRawSdkEventPayloads(); + for (const timer of this.rawSdkEventPersistTimers.values()) { + clearTimeout(timer); + } + this.rawSdkEventPersistTimers.clear(); + } + + private async mergeMessagesForSessionAliases( + aliasesByCanonicalId: Map, + ): Promise { log.debug("Starting session alias message merge", { aliasGroupCount: aliasesByCanonicalId.size, }); diff --git a/src/services/SkillManagerService.ts b/src/services/SkillManagerService.ts index 4b58405..32ce573 100644 --- a/src/services/SkillManagerService.ts +++ b/src/services/SkillManagerService.ts @@ -222,7 +222,7 @@ export class SkillManagerService { const axios = (await import('axios')).default; onProgress?.({ stage: 'downloading', percent: 10, message: 'Downloading skill...' }); - const response = await axios.get(url, { timeout: 30000 }); + const response = await axios.get(url, { timeout: 60_000 }); const skillData = response.data; onProgress?.({ stage: 'validating', percent: 30, message: 'Validating skill...' }); diff --git a/src/shared/.gitkeep b/src/shared/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/shared/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/types/.gitkeep b/src/types/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/types/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/utils/.gitkeep b/src/utils/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/utils/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/integration/models-timeout-fallback.test.mjs b/tests/integration/models-timeout-fallback.test.mjs index cffd4dd..fd1f2b2 100644 --- a/tests/integration/models-timeout-fallback.test.mjs +++ b/tests/integration/models-timeout-fallback.test.mjs @@ -31,13 +31,13 @@ test("handleGetModels falls back to cached/selected model after provider list ti assert.match( body, - /providerListTimeoutMs\s*=\s*8000/, + /providerListTimeoutMs\s*=\s*60_000/, "handleGetModels should fail fast instead of blocking startup for long provider discovery timeouts", ); assert.match( body, - /Promise\.race\(\s*\[\s*client\.provider\.list\(\)\s*,\s*timeoutPromise/s, - "handleGetModels should guard provider.list with an explicit timeout", + /this\.withTimeout\([\s\S]*client\.provider\.list\(\)[\s\S]*providerListTimeoutMs[\s\S]*["']Provider list["'][\s\S]*\)/s, + "handleGetModels should guard provider.list with the shared timeout helper", ); assert.match( body, diff --git a/tests/regression/evt-lifecycle-msg-coalesce-regression.test.mjs b/tests/regression/evt-lifecycle-msg-coalesce-regression.test.mjs index afca12b..52d092c 100644 --- a/tests/regression/evt-lifecycle-msg-coalesce-regression.test.mjs +++ b/tests/regression/evt-lifecycle-msg-coalesce-regression.test.mjs @@ -69,7 +69,7 @@ test("coalesceAssistantRunForCanonical prefers non-evt messages as the merge bas // ── canonicalizeMessagesForRender post-coalesce guard ─────────────── -test("canonicalizeMessagesForRender strips evt_ assistant messages when a non-evt assistant exists", () => { +test("canonicalizeMessagesForRender preserves evt_ assistant messages when live preservation is enabled", () => { const body = extractFunctionBody( storeSource, "export function canonicalizeMessagesForRender(", @@ -83,29 +83,50 @@ test("canonicalizeMessagesForRender strips evt_ assistant messages when a non-ev assert.match( body, - /return hasNonEvtAssistant[\s\S]*canonical\.filter[\s\S]*!isAssistantMessageForCanonical\(m\)[\s\S]*!\(typeof m\?\.info\?\.id === "string" && m\.info\.id\.startsWith\("evt_"\)\)[\s\S]*: canonical/, - "when a non-evt assistant is present all evt_ assistant messages must be stripped from the canonical array", + /return hasNonEvtAssistant && !options\?\.preserveEvtAssistantMessages[\s\S]*canonical\.filter[\s\S]*: canonical/, + "canonicalizeMessagesForRender should keep evt_ assistant messages available when live preservation is enabled", ); }); // ── visibleMessages render guard in ChatShell ─────────────────────── -test("ChatShell visibleMessages guards evt_ removal with non-evt presence check", () => { +test("ChatShell visibleMessages preserves evt_ entries while the assistant turn is still live", () => { assert.match( chatShellSource, - /hasNonEvtAssistant[\s\S]*sliced\.slice[\s\S]*\.some[\s\S]*!\(typeof [^\n]*\?\.info\?\.id === "string"[^\n]*\.info\.id\.startsWith\("evt_"\)\)/, + /const hasEvtPrefix =[\s\S]*startsWith\("evt_"\)[\s\S]*const hasNonEvtAssistant = sliced\.slice[\s\S]*!hasEvtPrefix\(m\)/, "visibleMessages must check whether any non-evt assistant exists in the current turn before stripping evt_ entries", ); assert.match( chatShellSource, - /if \(!hasNonEvtAssistant\)[\s\S]*return sliced/, - "when only evt_ assistant messages exist they must be preserved as the visible content", + /const shouldPreserveEvtAssistants =[\s\S]*state\.streaming\?\.isActive \|\| state\.assistantTurnPending/, + "visibleMessages should preserve evt_ assistants while the turn is still active", ); assert.match( chatShellSource, - /return sliced\.filter[\s\S]*!\(typeof [^\n]*\?\.info\?\.id === "string"[^\n]*\.info\.id\.startsWith\("evt_"\)\)/, - "when a non-evt assistant is present all evt_ entries must be stripped from visible messages", + /if \(!hasNonEvtAssistant \|\| shouldPreserveEvtAssistants\)[\s\S]*return sliced[\s\S]*return sliced\.filter\(\(m\) => !hasEvtPrefix\(m\)\);/s, + "evt_ entries should remain visible during live turns and only be stripped after the turn settles", + ); +}); + +test("messageResponse prefers canonical assistant ids before dropping mismatched snapshots", () => { + assert.match( + messageHandlerSource, + /const responseMessageId = getMessageId\(msg\);/, + "messageResponse should prefer the canonical helper instead of trusting a top-level evt_ id", + ); + assert.match( + messageHandlerSource, + /const responseIsEvtLifecycle = isEvtLifecycleMessageId\([\s\S]*const snapshotIsCanonicalAssistant =[\s\S]*const shouldDropMismatchedSnapshot =[\s\S]*!\(responseIsEvtLifecycle && snapshotIsCanonicalAssistant\);/s, + "messageResponse should preserve a canonical msg_ snapshot when the final payload only carries an evt_ lifecycle id", + ); +}); + +test("lifecycle message.updated cannot terminate an active canonical msg_ stream", () => { + assert.match( + messageHandlerSource, + /const shouldTreatLifecycleUpdateAsTerminal =[\s\S]*!finish[\s\S]*\(!currentStreamingMessageId \|\|[\s\S]*currentStreamingIsEvtLifecycle[\s\S]*currentStreamingMessageId === messageId\)/s, + "evt_ lifecycle updates should only finish streaming when the active snapshot is still evt_-owned or matches the same message id", ); }); diff --git a/tests/regression/raw-response-debug-visibility-regression.test.mjs b/tests/regression/raw-response-debug-visibility-regression.test.mjs index c01f2c3..4c298da 100644 --- a/tests/regression/raw-response-debug-visibility-regression.test.mjs +++ b/tests/regression/raw-response-debug-visibility-regression.test.mjs @@ -9,6 +9,11 @@ const messageComponentsSource = readSource( ); test("assistant raw response debug visibility is driven by config, not payload presence or debug flags", () => { + const rawResponseBlock = messageComponentsSource.match( + /const\s+\{\s*rawResponseText\s*\}\s*=\s*useMemo\(\(\)\s*=>\s*\{[\s\S]*?\},\s*\[centralizedRawResponse\]\);/, + )?.[0]; + assert.ok(rawResponseBlock, "raw response debug memo should be present"); + assert.match( messageComponentsSource, /const\s+showRawResponseDebug\s*=\s*config\.debug\.showRawResponse;/, @@ -20,6 +25,16 @@ test("assistant raw response debug visibility is driven by config, not payload p /const\s+visibleRawResponseText\s*=\s*rawResponseText\.trim\(\)\.length\s*>\s*0[\s\S]*\(rawResponse is missing on this message\)/, "raw response debug block should show a fallback message when rawResponse is missing", ); + assert.match( + messageComponentsSource, + /normalizeDebugStringForDisplay\(value: string\): unknown/, + "raw response debug text should normalize JSON-shaped strings for readability", + ); + assert.match( + messageComponentsSource, + /return\s+formatDebugObjectLiteral\(JSON\.parse\(trimmed\),\s*depth,\s*seen\);/, + "debug object literal rendering should parse JSON-shaped strings instead of quoting them", + ); assert.match( messageComponentsSource, @@ -28,8 +43,8 @@ test("assistant raw response debug visibility is driven by config, not payload p ); assert.doesNotMatch( - messageComponentsSource, - /showRawResponseDebug\s*&&\s*hasRawResponseDebug/, - "raw response debug visibility should not also require a payload-presence gate", + rawResponseBlock, + /compactDuplicateDebugFields\(displayValue\)/, + "raw response debug text should not compact the raw payload", ); }); diff --git a/tests/regression/reasoning-leak-response-card-regression.test.mjs b/tests/regression/reasoning-leak-response-card-regression.test.mjs index 9fed5dd..49d38bf 100644 --- a/tests/regression/reasoning-leak-response-card-regression.test.mjs +++ b/tests/regression/reasoning-leak-response-card-regression.test.mjs @@ -232,33 +232,33 @@ test('pending stream-content-as-thinking reasoning is pushed to the end of the t // ── Loading state mirrors stop button visibility ── -test('InputWrapper includes assistantTurnPending in isAiResponding for stop button visibility', () => { +test('InputWrapper derives stop-button visibility from current session processing state', () => { assert.match( panelSource, - /assistantTurnPending[\s\S]*isAiResponding/, - 'InputWrapper must destructure assistantTurnPending and use it in isAiResponding', - ); - assert.match( - panelSource, - /streaming\?\.isActive\s*\|\|[\s\S]*assistantTurnPending/, - 'isAiResponding must include assistantTurnPending alongside streaming?.isActive', + /const isAiResponding = isProcessing;/, + 'InputWrapper must derive isAiResponding directly from current session processing state', ); }); -test('StickyHeader shows Thinking... loading text when session is processing', () => { +test('StickyHeader does not render a duplicate Thinking... loading label', () => { assert.match( panelSource, - /isProcessing\s*&&\s*\([\s\S]*animate-pulse[\s\S]*Thinking/, - 'StickyHeader must show a pulsing Thinking... text when isProcessing is true', + /\{sessionTitle\}<\/span>/, + 'StickyHeader should keep the session title in the header', + ); + assert.doesNotMatch( + panelSource, + /animate-pulse[\s\S]*Thinking\.\.\./, + 'StickyHeader must not render a duplicate Thinking... label', ); }); // ── Lifecycle events with renderable text finish streaming to prevent stuck loading ── -test('lifecycle message.updated with renderable structured text triggers FINISH_STREAMING', () => { +test('lifecycle message.updated only triggers FINISH_STREAMING when the active stream is still lifecycle-owned', () => { assert.match( handlerSource, - /structuredKind\s*===\s*["']lifecycle["'][\s\S]*hasRenderableLiveStructuredUpdate/, - 'FINISH_STREAMING condition must include lifecycle events with renderable structured updates', + /const shouldTreatLifecycleUpdateAsTerminal =[\s\S]*structuredKind === "lifecycle"[\s\S]*hasRenderableLiveStructuredUpdate[\s\S]*!finish[\s\S]*currentStreamingIsEvtLifecycle[\s\S]*currentStreamingMessageId === messageId/s, + 'FINISH_STREAMING should only treat lifecycle updates as terminal when the active stream is still owned by that lifecycle event', ); }); diff --git a/tests/regression/store-message-canonicalization-regression.test.mjs b/tests/regression/store-message-canonicalization-regression.test.mjs index 4b09602..3181d5e 100644 --- a/tests/regression/store-message-canonicalization-regression.test.mjs +++ b/tests/regression/store-message-canonicalization-regression.test.mjs @@ -20,8 +20,8 @@ test("SET_MESSAGES uses centralized canonicalization before storing messages", ( assert.match( reducerBody, - /const\s+canonicalMessages\s*=\s*canonicalizeMessagesForRender\(action\.payload\);/, - "SET_MESSAGES should canonicalize payload via centralized reducer helper", + /const\s+canonicalMessages\s*=\s*canonicalizeMessagesForRender\(action\.payload,\s*\{[\s\S]*preserveEvtAssistantMessages:[\s\S]*\}\);/s, + "SET_MESSAGES should canonicalize payload via centralized reducer helper with live evt_ preservation awareness", ); assert.match( reducerBody, @@ -33,7 +33,7 @@ test("SET_MESSAGES uses centralized canonicalization before storing messages", ( test("centralized canonicalization drops internal reminders and coalesces assistant runs", () => { const canonicalBody = extractFunctionBody( storeSource, - "function canonicalizeMessagesForRender(messages: Message[]): Message[]", + "export function canonicalizeMessagesForRender(", ); const reminderBody = extractFunctionBody( storeSource, diff --git a/tests/regression/system-message-deduplication-regression.test.mjs b/tests/regression/system-message-deduplication-regression.test.mjs index aa68167..cad18f9 100644 --- a/tests/regression/system-message-deduplication-regression.test.mjs +++ b/tests/regression/system-message-deduplication-regression.test.mjs @@ -49,7 +49,7 @@ test.skip("system message deduplication includes system role in text-based dedup test.skip("system message deduplication prevents duplicate messages", () => { const canonicalizeBody = extractFunctionBody( storeSource, - "function canonicalizeMessagesForRender(messages: Message[]): Message[]", + "export function canonicalizeMessagesForRender(", ); // Verify that internal transport messages are converted to system role diff --git a/tests/services/quota-service.test.mjs b/tests/services/quota-service.test.mjs index a346178..737cfab 100644 --- a/tests/services/quota-service.test.mjs +++ b/tests/services/quota-service.test.mjs @@ -219,7 +219,7 @@ test('QuotaService implements HTTPS request helpers', () => { assert.match(postBody, /resolve\(\{\s*body:\s*data,\s*statusCode:\s*res\.statusCode\s*\|\|\s*200\s*\}\)/, 'httpsPost should return HttpResponse with body and status'); // Verify constants - assert.match(quotaServiceSource, /const\s+REQUEST_TIMEOUT_MS\s*=\s*10_000/, 'Should define 10s timeout'); + assert.match(quotaServiceSource, /const\s+REQUEST_TIMEOUT_MS\s*=\s*60_000/, 'Should define 60s timeout'); assert.match(quotaServiceSource, /const\s+USER_AGENT\s*=\s*"vscode-opencode-quota-monitor\/1\.0"/, 'Should define user agent'); }); diff --git a/tests/unit/config/timeout-validation.test.mjs b/tests/unit/config/timeout-validation.test.mjs index 7741e91..8c3642c 100644 --- a/tests/unit/config/timeout-validation.test.mjs +++ b/tests/unit/config/timeout-validation.test.mjs @@ -34,7 +34,7 @@ test("requestTimeout configuration has correct schema", () => { const requestTimeout = properties['opencode.requestTimeout']; assert.equal(requestTimeout?.type, 'number', 'requestTimeout should be number type'); - assert.equal(requestTimeout?.default, 120000, 'requestTimeout should default to 120000ms'); + assert.equal(requestTimeout?.default, 60000, 'requestTimeout should default to 60000ms'); assert.ok(requestTimeout?.minimum, 'requestTimeout should have minimum value'); assert.ok(requestTimeout?.maximum, 'requestTimeout should have maximum value'); }); diff --git a/tests/unit/core/history-processor-regression.test.mjs b/tests/unit/core/history-processor-regression.test.mjs index 54293d0..7d752ab 100644 --- a/tests/unit/core/history-processor-regression.test.mjs +++ b/tests/unit/core/history-processor-regression.test.mjs @@ -215,6 +215,11 @@ test.describe('History Processor - Message Merging', () => { /base\.content = visibleBodyText \|\| this\.extractMessageBodyText\(base\);/, 'burst coalescing should keep a visible assistant body even when the last record is empty', ); + assert.match( + source, + /latestRawSdkEventPayloads|rawSdkEventPayloads/, + 'burst coalescing should preserve rawSdkEventPayloads for rehydrated debug rendering', + ); }); test('mergeMessageParts combines related parts', () => { diff --git a/tests/unit/core/opencode-server-manager-regression.test.mjs b/tests/unit/core/opencode-server-manager-regression.test.mjs index cb4e2e7..4f32f83 100644 --- a/tests/unit/core/opencode-server-manager-regression.test.mjs +++ b/tests/unit/core/opencode-server-manager-regression.test.mjs @@ -105,8 +105,8 @@ test.describe('OpencodeServerManager - Server Startup', () => { assert.match( source, - /startServer[\s\S]*setTimeout.*10000|startupTimeout|timeout/s, - 'must implement 10-second startup timeout' + /startServer[\s\S]*setTimeout.*60000|startupTimeout|timeout/s, + 'must implement 60-second startup timeout' ); }); @@ -149,8 +149,14 @@ test.describe('OpencodeServerManager - Port Management', () => { assert.match( source, - /isPortReachable[\s\S]*Socket.*connect|timeout.*800/s, - 'must test port connectivity' + /const LOOPBACK_HOST = "127\.0\.0\.1"/, + 'must define a shared loopback host constant' + ); + + assert.match( + source, + /isPortReachable[\s\S]*Socket.*connect[\s\S]*LOOPBACK_HOST/s, + 'must test port connectivity through the shared loopback host constant' ); }); @@ -183,8 +189,14 @@ test.describe('OpencodeServerManager - Client Connection', () => { assert.match( source, - /connectToServer[\s\S]*createOpencodeClient|baseUrl.*localhost/s, - 'must create SDK client with server URL' + /connectToServer[\s\S]*createOpencodeClient/, + 'must create SDK client with a loopback server URL' + ); + + assert.match( + source, + /baseUrl:[\s\S]*LOOPBACK_HOST/, + 'must create SDK client with the shared loopback host constant' ); }); @@ -417,7 +429,7 @@ test.describe('OpencodeServerManager - Error Handling', () => { assert.match( source, - /Server startup timeout|startupTimeout|10.*000/s, + /Server startup timeout|startupTimeout|60.*000/s, 'must handle server startup timeout' ); }); diff --git a/tests/unit/model-agent-manager.test.mjs b/tests/unit/model-agent-manager.test.mjs index ca0819b..b48a761 100644 --- a/tests/unit/model-agent-manager.test.mjs +++ b/tests/unit/model-agent-manager.test.mjs @@ -28,9 +28,9 @@ test('handleGetModels uses single-flight fetch, provider timeout, and fallback m ); assert.match(handleGetModelsBody, /if \(this\.modelsFetchPromise\) \{[\s\S]*return this\.modelsFetchPromise;/, 'handleGetModels should reuse an in-flight fetch promise'); - assert.match(handleGetModelsBody, /const providerListTimeoutMs = 8000;/, 'handleGetModels should enforce an 8 second provider list timeout'); - assert.match(handleGetModelsBody, /Promise\.race\(\[[\s\S]*client\.provider\.list\(\),[\s\S]*timeoutPromise,[\s\S]*\]\)/, 'handleGetModels should race provider listing against the timeout promise'); - assert.match(handleGetModelsBody, /const fallbackModels = cachedModels\.length > 0 \? cachedModels : this\.getSelectedModelFallbackList\(\);/, 'handleGetModels should fall back to cached or selected-model-derived options'); + assert.match(handleGetModelsBody, /const providerListTimeoutMs = 60_000;/, 'handleGetModels should enforce a 60 second provider list timeout'); + assert.match(handleGetModelsBody, /this\.withTimeout\([\s\S]*client\.provider\.list\(\)[\s\S]*providerListTimeoutMs[\s\S]*["']Provider list["'][\s\S]*\)/, 'handleGetModels should wrap provider listing in the shared timeout helper'); + assert.match(handleGetModelsBody, /const fallbackModels =[\s\S]*cachedModels\.length > 0[\s\S]*this\.getSelectedModelFallbackList\(\)/, 'handleGetModels should fall back to cached or selected-model-derived options'); assert.match(handleGetModelsBody, /this\.postMessage\(\{[\s\S]*type: "modelsList",[\s\S]*models: fallbackModels,[\s\S]*selectedModel: this\.selectedModel,[\s\S]*\}\);/, 'handleGetModels should publish fallback model state to the webview'); }); diff --git a/tests/unit/services/SessionService.test.mjs b/tests/unit/services/SessionService.test.mjs index e071c7f..91b7a23 100644 --- a/tests/unit/services/SessionService.test.mjs +++ b/tests/unit/services/SessionService.test.mjs @@ -348,6 +348,16 @@ test('SessionService implements message merge utilities', () => { /function\s+mergeRicherMessageFields\(/, 'Should define mergeRicherMessageFields function' ); + assert.match( + sessionServiceSource, + /rawSdkEventPayloads/, + 'mergeRicherMessageFields should preserve rawSdkEventPayloads' + ); + assert.match( + sessionServiceSource, + /rawResponse/, + 'mergeRicherMessageFields should preserve rawResponse' + ); }); test('SessionService implements state initialization', () => { @@ -499,6 +509,16 @@ test('SessionService implements sanitizeForPersistence', () => { /Object\.entries\(obj\)\.slice\(0,\s*MAX_PERSISTED_OBJECT_KEYS\)/, 'sanitizeForPersistence should limit object keys' ); + assert.match( + sanitizeBody, + /rawSdkEventPayloads/, + 'sanitizeForPersistence should preserve rawSdkEventPayloads even when object keys are truncated' + ); + assert.match( + sanitizeBody, + /rawResponse/, + 'sanitizeForPersistence should preserve rawResponse even when object keys are truncated' + ); }); test('SessionService implements compaction functions for persistence', () => { @@ -517,6 +537,11 @@ test('SessionService implements compaction functions for persistence', () => { /function\s+compactSubagentForPersistence\(/, 'Should define compactSubagentForPersistence' ); + assert.match( + sessionServiceSource, + /compact\.rawSdkEventPayloads\s*=\s*\(rec\.rawSdkEventPayloads as unknown\[\]\)/, + 'compactMessageForPersistence should retain rawSdkEventPayloads' + ); }); test('SessionService defines compaction limits', () => { diff --git a/tests/unit/streaming-stop-button-sync.test.mjs b/tests/unit/streaming-stop-button-sync.test.mjs index 8e003e9..c80e773 100644 --- a/tests/unit/streaming-stop-button-sync.test.mjs +++ b/tests/unit/streaming-stop-button-sync.test.mjs @@ -8,10 +8,10 @@ const panelSource = readSource( 'PanelComponents.tsx', ); -test('InputWrapper limits stop/send toggle to abortable assistant response state', () => { +test('InputWrapper derives stop/send toggle from the active assistant response state', () => { assert.match( panelSource, - /const isAiResponding = !!\([\s\S]*isProcessing[\s\S]*streaming\?\.isActive[\s\S]*!\s*hasCompletedAssistantReplyForLatestTurn[\s\S]*\);/, - 'InputWrapper should only treat active or pre-reply streaming as stop-worthy', + /const isAiResponding = isAssistantRespondingInCurrentSession\([\s\S]*Boolean\(streaming\?\.isActive\)[\s\S]*assistantTurnPending/s, + 'InputWrapper should treat the full active-assistant turn state as stop-worthy', ); }); diff --git a/tests/unit/system-message-rendering-fix.test.mjs b/tests/unit/system-message-rendering-fix.test.mjs index 15d922d..67c6acb 100644 --- a/tests/unit/system-message-rendering-fix.test.mjs +++ b/tests/unit/system-message-rendering-fix.test.mjs @@ -80,7 +80,7 @@ test("message ordering fix: canonicalizeMessagesForRender stabilizes hydrated hi ); const canonicalizeBody = extractFunctionBody( webviewStoreSource, - "export function canonicalizeMessagesForRender(messages: Message[]): Message[] {", + "export function canonicalizeMessagesForRender(", ); assert.match( diff --git a/tests/webview/chat-flow-integration.test.mjs b/tests/webview/chat-flow-integration.test.mjs index a7b8c9e..d366d6d 100644 --- a/tests/webview/chat-flow-integration.test.mjs +++ b/tests/webview/chat-flow-integration.test.mjs @@ -38,6 +38,11 @@ const messageComponentsSource = readSource( 'MessageComponents.tsx', ); +const rawResponseSource = readSource( + [joinFromRoot('webview', 'shared', 'src', 'chat', 'lib', 'rawResponse.ts')], + 'rawResponse.ts', +); + const chatShellSource = readSource( [joinFromRoot('webview', 'shared', 'src', 'chat', 'ChatShell.tsx')], 'ChatShell.tsx', @@ -157,12 +162,116 @@ test('subagent flow handles multiple concurrent subagents with deduplication', ( // Subagent deduplication by ID assert.match(handlerBody, /UPSERT_SUBAGENT_SUMMARIES/, 'subagent summaries are upserted (handles deduplication)'); assert.match(handlerBody, /UPSERT_SUBAGENT_DETAIL/, 'subagent details are upserted (handles deduplication)'); + assert.match( + messageComponentsSource, + /dedupeSubagentsById/, + 'assistant message rendering should normalize duplicate subagent IDs before paint', + ); + assert.match( + messageComponentsSource, + /mergedById/, + 'assistant message rendering should merge the store and message subagent views by ID', + ); // Subagent state management assert.match(reducerBody, /case\s+["']UPSERT_SUBAGENT_SUMMARIES["']:/, 'reducer handles UPSERT_SUBAGENT_SUMMARIES action'); assert.match(reducerBody, /case\s+["']UPSERT_SUBAGENT_DETAIL["']:/, 'reducer handles UPSERT_SUBAGENT_DETAIL action'); }); +test('centralized debug data keeps raw event stream and raw rehydrated data separate', () => { + const centralizedDebugStart = messageComponentsSource.indexOf( + 'const centralizedDebugData = useMemo(', + ); + const centralizedDebugEnd = messageComponentsSource.indexOf( + 'const hasPendingReasoningDisplayEvent', + centralizedDebugStart, + ); + const centralizedDebugBlock = + centralizedDebugStart >= 0 && centralizedDebugEnd > centralizedDebugStart + ? messageComponentsSource.slice(centralizedDebugStart, centralizedDebugEnd) + : ''; + + assert.match( + messageComponentsSource, + /useMemo\(/, + 'the centralized debug payload should be explicitly typed', + ); + assert.match( + centralizedDebugBlock, + /rawEventStream[\s\S]*rawRehydrated/, + 'the centralized debug payload should expose both raw event stream and raw rehydrated sources', + ); + assert.doesNotMatch( + centralizedDebugBlock, + /JSON\.parse|JSON\.stringify|compactDebugTimeline|dedupeDebugArray|rawStreamingSnapshot|rawActivityTimeline/, + 'the centralized debug payload should not be compacted or include extra derived layers', + ); + assert.ok( + messageComponentsSource.includes('CentralizedDebugData'), + 'the centralized debug component should import the shared centralized debug type', + ); + assert.match( + centralizedDebugBlock, + /rawSdkEventPayloads:[\s\S]*activityTimelineStreaming\.rawSdkEventPayloads/, + 'the event stream source should keep its raw SDK payload tape', + ); + assert.match( + centralizedDebugBlock, + /rawMessages:[\s\S]*rawMessagesBySessionId/, + 'the rehydrated source should keep the raw session history payload', + ); + assert.match( + messageComponentsSource, + //, + 'the centralized debug payload should render as a nested object tree instead of a string blob', + ); + assert.doesNotMatch( + messageComponentsSource, + /data-assistant-section="centralized-debug"[\s\S]*stringifyDebugValue\(centralizedDebugData\)/, + 'the centralized debug UI should not stringify the centralized payload', + ); +}); + +test('canonical assistant text reconstruction preserves spacing between parts', () => { + assert.match( + storeSource, + /function\s+contentFromRenderablePartsForCanonical\(parts: unknown\[\]\): string \{[\s\S]*\.join\(" "\)[\s\S]*\.trim\(\);/, + 'part-only assistant content should be rebuilt with spaces instead of being glued together', + ); + assert.match( + storeSource, + /parseRawResponseRecordForCanonical\([\s\S]*const rawResponseParts = Array\.isArray\(rawResponseRec\?\.parts\)[\s\S]*return contentFromRenderablePartsForCanonical\(rawResponseParts\);/, + 'assistant text reconstruction should fall back to rawResponse.parts when normal message content is missing', + ); +}); + +test('response card prefers raw sdk payload before transformed message fields', () => { + const responseCardBlock = messageComponentsSource.match( + /function getMessageContent\([\s\S]*?type RawDebugParseStatus =/, + )?.[0]; + assert.ok(responseCardBlock, 'response card content resolver should be present'); + assert.match( + rawResponseSource, + /export function getFinalAssistantResponseText\([\s\S]*?const structured = asRecord\(rawInfoRec\?\.structured\);[\s\S]*?const structuredMessage = firstNonEmptyString\([\s\S]*?const partsBody = finalTextBeforeStepFinish\(/, + 'rawResponse helper should check structured.message first and then the parts fallback', + ); + assert.match( + responseCardBlock, + /const baseContent = getFinalAssistantResponseText\(rawResponse \?\? message\.rawResponse\);/, + 'the response card should prefer the raw SDK response helper before any transformed message fields', + ); + assert.doesNotMatch( + responseCardBlock, + /firstNonEmptyString\([\s\S]*message\.content|firstNonEmptyString\([\s\S]*message\.text|firstNonEmptyString\([\s\S]*summaryText\(message\)/, + 'the response card resolver should not fall back to transformed message fields', + ); + assert.doesNotMatch( + rawResponseSource, + /\brawResponse\.text\b|\brawResponse\.content\b/, + 'the raw response helper should not use extra text/content fallbacks', + ); +}); + // =========================================================================== // TODOLIST INTEGRATION IN CHAT FLOW // =========================================================================== @@ -366,4 +475,4 @@ test('attachment and context flow: add -> validate -> send -> cleanup', () => { assert.match(inputBody, /files|contexts|payload|send/i, 'files and contexts are included in send'); assert.match(inputBody, /images|attachments|include/i, 'attachments are included as images in sendMessage'); assert.match(inputBody, /CLEAR_ATTACHMENTS|clear|cleanup/i, 'attachments are cleared after message is sent'); -}); \ No newline at end of file +}); diff --git a/tests/webview/chat-message-flow.test.mjs b/tests/webview/chat-message-flow.test.mjs index 7762ad3..39331a5 100644 --- a/tests/webview/chat-message-flow.test.mjs +++ b/tests/webview/chat-message-flow.test.mjs @@ -177,11 +177,11 @@ test('Assistant responses include dedicated enter transition classes', () => { assert.match(chatCssSource, /\.oc-assistant-streaming-enter\s*\{[\s\S]*assistant-streaming-in/, 'chat css should define animation for streaming assistant responses'); }); -test('live reasoning stays out of the assistant response card while streaming', () => { +test('live reasoning remains available to the streaming timeline while streaming', () => { assert.match( messageSource, - /const\s+thoughtItems\s*=\s*useMemo\(\s*\(\)\s*=>\s*streaming\s*\?\s*\(\s*streaming\.isActive\s*&&\s*!hasAssistantFinishSignal\s*\?\s*\[\]\s*:\s*thoughtItemsFromStreaming\(streaming\)\s*\)\s*:\s*thoughtItemsFromMessage\(message\)/s, - 'AssistantMessage should suppress live reasoning items until the assistant finish signal exists', + /const\s+thoughtItems\s*=\s*useMemo\(\s*\(\)\s*=>\s*streaming\s*\?\s*thoughtItemsFromStreaming\(streaming\)\s*:\s*thoughtItemsFromMessage\(message\)/s, + 'AssistantMessage should source live reasoning items directly from streaming state', ); assert.match( messageSource, diff --git a/tests/webview/chat-view-streaming.test.mjs b/tests/webview/chat-view-streaming.test.mjs index 03a1140..7416193 100644 --- a/tests/webview/chat-view-streaming.test.mjs +++ b/tests/webview/chat-view-streaming.test.mjs @@ -6,6 +6,14 @@ import { extractFunctionBody, joinFromRoot, readAllSources, readSource } from '. const chatProviderSource = readAllSources([joinFromRoot('src', 'providers', 'ChatViewProvider.ts'), 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'), joinFromRoot('src', 'providers', 'chat', 'StreamEventHandler.ts'), joinFromRoot('src', 'providers', 'chat', 'ModelAndAgentManager.ts'), joinFromRoot('src', 'providers', 'chat', 'SessionHandler.ts')], 'ChatViewProvider.ts', ); +const messageStreamSource = readSource( + [joinFromRoot('src', 'services', 'MessageStreamService.ts')], + 'MessageStreamService.ts', +); +const messageHandlerSource = readSource( + [joinFromRoot('webview', 'shared', 'src', 'chat', 'lib', 'messageHandler.ts')], + 'messageHandler.ts', +); test('ChatViewProvider streams events to webview progressively', () => { const registerHandlersBody = extractFunctionBody( @@ -43,6 +51,40 @@ test('ChatViewProvider streams events to webview progressively', () => { ); }); +test('MessageStreamService logs stream events when they are received', () => { + assert.match( + messageStreamSource, + /this\.logger\.info\("\[CHAT-STREAMING\]\[KEY1\] Stream event received from server"/, + 'MessageStreamService should log each inbound stream event before filtering or dispatch', + ); +}); + +test('ChatViewProvider logs stream events before posting them to the webview', () => { + const registerHandlersBody = extractFunctionBody( + chatProviderSource, 'resolveWebviewView(', + ); + + assert.match( + registerHandlersBody, + /this\.logger\.info\("\[CHAT-STREAMING\]\[KEY2\] Forwarding stream event to webview"/, + 'ChatViewProvider should log each stream event before webview postMessage', + ); +}); + +test('webview message handler logs stream events before reducer/render processing', () => { + const streamEventCase = messageHandlerSource.slice( + messageHandlerSource.indexOf('case "streamEvent"'), + messageHandlerSource.indexOf('case "streamEventEnrich"'), + ); + + assert.ok(streamEventCase.length > 0, 'streamEvent case must exist'); + assert.match( + streamEventCase, + /logger\.info\("\[CHAT-STREAMING\]\[KEY3\] Applying event to reducer"/, + 'webview message handler should log stream events before state is reduced/rendered', + ); +}); + test('ChatViewProvider backfills missing stream event sessionId from active session', () => { const registerHandlersBody = extractFunctionBody( chatProviderSource, 'resolveWebviewView(', diff --git a/tests/webview/chatshell-rendering-conditions.test.mjs b/tests/webview/chatshell-rendering-conditions.test.mjs index b2d2470..fe4d0a0 100644 --- a/tests/webview/chatshell-rendering-conditions.test.mjs +++ b/tests/webview/chatshell-rendering-conditions.test.mjs @@ -41,7 +41,7 @@ test('session switching shows the session loading spinner', () => { test('empty state appears when there are no messages and no streaming activity', () => { assert.match( chatContentBody, - /state\.messages\.length === 0[\s\S]*!state\.streaming[\s\S]*!isAiResponding[\s\S]*/, + /state\.messages\.length === 0[\s\S]*!state\.streaming[\s\S]*!isAiResponding[\s\S]* { assert.match(chatContentBody, /else if \(\(msg as Record\)\.type === "permission"\) \{[\s\S]*/s, 'permission messages should render PermissionCard'); - assert.match(chatContentBody, /const pendingAssistantTurnMessageId =[\s\S]*state\.assistantTurnMessageId\s*\?\?\s*null/s, 'ChatShell should track the pending assistant turn message id'); - assert.match(chatContentBody, /const isLiveStreamingAssistantTurn =[\s\S]*state\.assistantTurnPending[\s\S]*pendingAssistantTurnMessageId === messageId/s, 'ChatShell should identify the live assistant turn by either active stream or pending turn state'); - assert.match(chatContentBody, /if \(isLiveStreamingAssistantTurn\) \{[\s\S]*messageNode = null;[\s\S]*\} else \{[\s\S]*/s, 'ChatShell should skip the in-flight assistant turn in the message list and let the live streaming card own it'); + assert.match(chatContentBody, /const isLiveStreamingAssistantTurn =[\s\S]*state\.streaming\?\.isActive[\s\S]*streamingMessageId === messageId/s, 'ChatShell should identify the live assistant turn from the active stream message id'); + assert.match(chatContentBody, / { @@ -66,8 +66,13 @@ test('thinking bubble appears while AI is responding before assistant text arriv ); assert.match( chatContentBody, - /const showAiResponseLoading =[\s\S]*isAiResponding[\s\S]*!state\.isCompacting[\s\S]*!hasAssistantText/s, - 'thinking bubble should remain visible for activity-only streams until assistant text arrives', + /const isAiStillResponding = isAssistantRespondingInCurrentSession\([\s\S]*Boolean\(state\.streaming\?\.isActive\)[\s\S]*state\.assistantTurnPending/s, + 'ChatShell should treat live stream activity and assistant-turn pending state as still responding', + ); + assert.match( + chatContentBody, + /const showAiResponseLoading =[\s\S]*isAiStillResponding[\s\S]*!state\.isCompacting/s, + 'thinking bubble should remain visible while the assistant turn is still active', ); assert.match( chatContentBody, @@ -79,7 +84,7 @@ test('thinking bubble appears while AI is responding before assistant text arriv test('streaming card is rendered at the bottom of the message list', () => { assert.match( chatContentBody, - / { - assert.match(panelComponents, /isAiResponding && inputValue\.trim\(\)\.length === 0/, 'stop button guard is missing'); +test('shows stop button whenever the assistant is responding', () => { + assert.match(panelComponents, /{isAiResponding \? \(/, 'stop button guard is missing'); assert.match(panelComponents, /type: "stopRequest"/, 'stopRequest transport is missing'); }); -test('derives stop-button visibility from abortable assistant response state', () => { +test('derives stop-button visibility from the same assistant response state as the loading indicator', () => { assert.match( panelComponents, - /const hasCompletedAssistantReplyForLatestTurn = \(\(\) => \{/, - 'InputWrapper should detect whether the latest assistant turn is already complete', + /const isAiResponding = isAssistantRespondingInCurrentSession\([\s\S]*Boolean\(streaming\?\.isActive\)[\s\S]*assistantTurnPending/s, + 'InputWrapper should derive stop visibility from the full active-assistant turn state', ); assert.match( panelComponents, - /const isAiResponding = !!\([\s\S]*streaming\?\.isActive[\s\S]*!\s*hasCompletedAssistantReplyForLatestTurn[\s\S]*\);/, - 'InputWrapper should only show stop for an actively abortable assistant turn', + /{isAiResponding \? \([\s\S]*oc-toolbar-action-icon-stop/, + 'InputWrapper should render the stop button directly from isAiResponding', ); }); diff --git a/tests/webview/raw-data-capture-regression.test.mjs b/tests/webview/raw-data-capture-regression.test.mjs new file mode 100644 index 0000000..044a442 --- /dev/null +++ b/tests/webview/raw-data-capture-regression.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { joinFromRoot, readSource } from '../helpers/source-utils.mjs'; + +const messageHandlerSource = readSource( + [joinFromRoot('webview', 'shared', 'src', 'chat', 'lib', 'messageHandler.ts')], + 'messageHandler.ts', +); +const sessionServiceSource = readSource( + [joinFromRoot('src', 'services', 'SessionService.ts')], + 'SessionService.ts', +); +const chatViewProviderSource = readSource( + [joinFromRoot('src', 'providers', 'chat', 'SessionHandler.ts')], + 'SessionHandler.ts', +); +const messageComponentsSource = readSource( + [joinFromRoot('webview', 'shared', 'src', 'chat', 'MessageComponents.tsx')], + 'MessageComponents.tsx', +); + +test('raw stream capture stores the incoming SDK payload without shaping it', () => { + assert.ok( + messageHandlerSource.includes('event: payload,'), + 'stream capture should store the incoming payload object directly', + ); + assert.ok( + messageHandlerSource.includes('type: "APPEND_RAW_SDK_EVENT_PAYLOAD"'), + 'stream capture should append the raw payload to the raw event tape', + ); +}); + +test('raw session persistence stores server raw messages before normalization', () => { + assert.match( + sessionServiceSource, + /await this\.saveSessionRawMessages\(sessionId, response\.data\);/s, + 'server session payload should be saved raw before merge/normalization', + ); + assert.match( + chatViewProviderSource, + /type:\s*"chatHistory"[\s\S]*rawMessages:\s*fallbackMessages,/s, + 'rehydration should forward the raw history payload to the webview', + ); +}); + +test('centralized debug raw rehydration only reads the raw session cache', () => { + assert.match( + messageComponentsSource, + /const rehydratedMessages =[\s\S]*rawMessagesBySessionId\?\.\[centralizedSessionId\]/s, + 'centralized debug should read the raw session cache for rehydrated data', + ); + assert.doesNotMatch( + messageComponentsSource, + /messagesBySessionId\?\.\[centralizedSessionId\]/, + 'centralized debug should not fall back to normalized messages for rehydrated raw data', + ); +}); diff --git a/tests/webview/stepper-autoscroll-and-flow.test.mjs b/tests/webview/stepper-autoscroll-and-flow.test.mjs index 82fdc62..b506171 100644 --- a/tests/webview/stepper-autoscroll-and-flow.test.mjs +++ b/tests/webview/stepper-autoscroll-and-flow.test.mjs @@ -491,7 +491,7 @@ test('completed activity is condensed to MAX_VISIBLE_COMPLETED_ACTIVITY=5', () = ); assert.match( messageComponentsSource, - /displayEvents\.slice\(-MAX_VISIBLE_COMPLETED_ACTIVITY\)/, + /mainDisplayEvents\.slice\(-MAX_VISIBLE_COMPLETED_ACTIVITY\)/, 'Condensed view should show the last N events', ); }); @@ -531,13 +531,13 @@ test('activityStatusCounts derives pending/done/error counts from userFacingDisp test('AssistantMessage computes displayEvents from timelineBlocks via buildDisplayEvents', () => { assert.match( messageComponentsSource, - /const displayEvents\s*=\s*useMemo\(\s*\(\)\s*=>\s*buildDisplayEvents\(timelineBlocks,\s*message,\s*isStreamingActive\)/, - 'displayEvents should be derived from buildDisplayEvents(timelineBlocks, message, isStreamingActive)', + /const events = buildDisplayEvents\(timelineBlocks,\s*message,\s*isStreamingActive,\s*assistantTurnPending\);/, + 'displayEvents should be derived from buildDisplayEvents(timelineBlocks, message, isStreamingActive, assistantTurnPending)', ); assert.match( messageComponentsSource, - /\[timelineBlocks,\s*message,\s*isStreamingActive\]/, - 'displayEvents memo deps should include timelineBlocks, message and isStreamingActive', + /\[timelineBlocks,\s*message,\s*isStreamingActive,\s*assistantTurnPending\]/, + 'displayEvents memo deps should include timelineBlocks, message, isStreamingActive and assistantTurnPending', ); }); @@ -697,16 +697,21 @@ test('activity display events receive an "activity" CSS class on the label span' // 15. File path rendering in activity steps // --------------------------------------------------------------------------- -test('step with a filePath renders an openFile button instead of plain summary', () => { +test('call-style and background task labels skip the file-link rendering path', () => { assert.match( messageComponentsSource, - /filePath|openFile|file/i, - 'When a step has a filePath, clicking should post an openFile message', + /event\.filePath\s*&&\s*!isUrl\(event\.filePath\)\s*&&\s*!isCallStyleActivityLabel\(event\.label\)/, + 'The file-link branch should exclude call-style and background task labels', ); assert.match( messageComponentsSource, - /filePath|title|accessibility/i, - 'File button should expose the full path via title for accessibility', + /normalized\.startsWith\(["']call_["']\)/i, + 'Call-style labels should be detected case-insensitively', + ); + assert.match( + messageComponentsSource, + /normalized\s*===\s*["']background_task["'][\s\S]*?normalized\s*===\s*["']background task["'][\s\S]*?normalized\s*===\s*["']background-task["']/i, + 'Background task labels should be grouped with call-style labels so they do not use the file icon path', ); }); diff --git a/tests/webview/streaming-components.test.mjs b/tests/webview/streaming-components.test.mjs index 7b455b3..4f715cc 100644 --- a/tests/webview/streaming-components.test.mjs +++ b/tests/webview/streaming-components.test.mjs @@ -24,11 +24,11 @@ test('exports ProgressSteps with StreamingStep array prop', () => { ); }); -test('exports StreamingCard as memo with isContiguous and streaming props', () => { +test('exports StreamingCard as memo with live assistant context props', () => { assert.match( source, - /export const StreamingCard = memo\(function StreamingCard\(\{ isContiguous, streaming \}/, - 'StreamingComponents.tsx must export StreamingCard as memo with isContiguous and streaming props', + /export const StreamingCard = memo\(function StreamingCard\(\{[\s\S]*isContiguous,[\s\S]*streaming,[\s\S]*interactiveEvents,[\s\S]*messages,[\s\S]*assistantTurnMessageId,/, + 'StreamingComponents.tsx must export StreamingCard as memo with live assistant context props', ); }); @@ -48,6 +48,21 @@ test('StreamingCard remains mounted for live assistant activity', () => { /if \(streaming\.steps\.length > 0 \|\| streaming\.progressEvents\.length > 0\) return true;/, 'StreamingCard should still render for live step/progress activity', ); + assert.match( + source, + /Array\.isArray\(interactiveEvents\) && interactiveEvents\.length > 0/, + 'StreamingCard should stay mounted when live interactive events exist only in app state', + ); + assert.match( + source, + /const liveSubagents = subagentsByParentMessageId\?\.\[streaming\.messageId\]/, + 'StreamingCard should stay mounted when live subagent updates are keyed by the current streaming message id', + ); + assert.match( + source, + /const liveAssistantMessage = useMemo\([\s\S]*assistantTurnMessageId\?\.trim\(\)[\s\S]*streaming\.messageId\?\.trim\(\)[\s\S]*return \[\.\.\.messages\]\.reverse\(\)\.find\(\(message\) => message\?\.role === "assistant"\)\;/, + 'StreamingCard should attach the in-flight assistant message so the live render can see message-scoped activity', + ); }); test('defines extClass helper for file extension CSS mapping', () => { diff --git a/webview/shared/package-lock.json b/webview/shared/package-lock.json index 7538956..2a9c35a 100644 --- a/webview/shared/package-lock.json +++ b/webview/shared/package-lock.json @@ -1830,9 +1830,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1847,9 +1844,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1864,9 +1858,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1881,9 +1872,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1898,9 +1886,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1915,9 +1900,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1932,9 +1914,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1949,9 +1928,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1966,9 +1942,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1983,9 +1956,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2000,9 +1970,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2017,9 +1984,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2034,9 +1998,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2183,14 +2144,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2201,7 +2162,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -2502,7 +2463,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { diff --git a/webview/shared/package.json b/webview/shared/package.json index b16b14a..7ea5b3c 100644 --- a/webview/shared/package.json +++ b/webview/shared/package.json @@ -4,7 +4,6 @@ "version": "0.0.1", "type": "module", "scripts": { - "prebuild": "node ../../scripts/sync-structured-output-contract.mjs", "build": "vite build && node ../../scripts/merge-css.mjs", "dev": "vite" }, diff --git a/webview/shared/src/chat/ChatShell.tsx b/webview/shared/src/chat/ChatShell.tsx index efc84f9..03ebec9 100644 --- a/webview/shared/src/chat/ChatShell.tsx +++ b/webview/shared/src/chat/ChatShell.tsx @@ -3,7 +3,12 @@ import { Archive, X } from "lucide-react"; import { AppProvider, useAppDispatch, useAppState } from "./lib/store"; import { createMessageHandler } from "./lib/messageHandler"; -import { isProcessingInCurrentSession } from "./lib/sessionProcessing"; +import { + isAssistantRespondingInCurrentSession, + hasActiveAssistantTurnContext, + hasCompletedAssistantReplyForLatestTurn, + isProcessingInCurrentSession, +} from "./lib/sessionProcessing"; import vscode from "./lib/vscode"; import logger, { getGlobalShowBrowserConsole } from "./lib/logger"; @@ -21,9 +26,10 @@ import { SkillsPanel, SettingsPanel, } from "./PanelComponents"; +import { CentralizedToastOverlay } from "./ToastOverlay"; import { StreamingCard } from "./StreamingComponents"; import { - AssistantMessage, + AssistantResponseCard, EmptyState, PermissionCard, SystemMessage, @@ -430,6 +436,24 @@ function ChatContent() { state.currentSessionId, state.processingSessionIds, ); + const hasCompletedAssistantReply = + hasCompletedAssistantReplyForLatestTurn(state.messages); + const hasActiveTurnContext = hasActiveAssistantTurnContext( + state.messages, + Boolean(state.streaming?.isActive), + state.assistantTurnPending, + ); + const isAiStillResponding = isAssistantRespondingInCurrentSession( + state.isProcessing, + state.currentSessionId, + state.processingSessionIds, + Boolean(state.streaming?.isActive), + state.assistantTurnPending, + state.streaming?.hasAssistantFinishSignal, + state.streaming?.hasTerminalStepSignal, + hasCompletedAssistantReply, + hasActiveTurnContext, + ); // Check if we're switching to a different session (loading conversation) // Uses the new isLoadingSession state which is set during session switches @@ -445,29 +469,6 @@ function ChatContent() { state.messages.length > 0 || hasCachedCurrentSessionMessages; const isConnecting = false; - const hasCompletedAssistantReplyForLatestTurn = (() => { - if (state.messages.length === 0) { - return false; - } - for (let i = state.messages.length - 1; i >= 0; i -= 1) { - const message = state.messages[i]; - if (message.role === "assistant") { - const text = typeof message.content === "string" ? message.content.trim() : ""; - const structuredText = - typeof message.structuredOutput?.message === "string" - ? message.structuredOutput.message.trim() - : ""; - if (text.length > 0 || structuredText.length > 0) { - return true; - } - continue; - } - if (message.role === "user") { - return false; - } - } - return false; - })(); const compatibilityWarningSignature = state.compatibilityWarnings .map((warning) => `${warning.component}:${warning.version ?? "unknown"}:${warning.status}:${warning.supportedRange}`) .join("|"); @@ -498,9 +499,9 @@ function ChatContent() { ); } - // Show AI response loading indicator until assistant text arrives. Activity - // streams can render tool/progress rows before text starts, so the response - // loading affordance should key off text presence instead of stream presence. + // Keep the loading bubble visible for the entire active assistant turn. + // Live stream payloads can arrive before the final assistant message is + // finalized, but the user still needs a clear "AI is responding" signal. const hasAssistantText = !!state.streaming?.content && state.streaming.content.trim().length > 0; @@ -512,19 +513,20 @@ function ChatContent() { state.streaming.progressEvents.length > 0 || state.streaming.edits.length > 0 || (Array.isArray(state.streaming.interactiveEvents) && - state.streaming.interactiveEvents.length > 0)), + state.streaming.interactiveEvents.length > 0) || + (Array.isArray(state.interactiveEvents) && + state.interactiveEvents.length > 0)), ); // Show AI response loading indicator (thinking bubble) when: // 1. NOT switching sessions (session loading takes precedence), AND - // 2. AI is responding but no renderable content has arrived yet. + // 2. AI is still responding and the assistant turn has not finalized yet. // FIXED: Use hasRenderableContent from SDK instead of checking content length const hasRenderableStreamingContent = Boolean(state.streaming?.hasRenderableContent); const showAiResponseLoading = !state.isLoadingSession && // Direct state check to avoid timing issues - isAiResponding && // Must still be processing (not stopped) - !hasCompletedAssistantReplyForLatestTurn && - !state.isCompacting && - !hasRenderableStreamingContent; // Use SDK's renderable flag instead of content length + isAiStillResponding && // Keep loading affordance visible while the turn is active + !hasCompletedAssistantReply && + !state.isCompacting; // Enforce minimum display duration for loading state // This ensures users can perceive the loading indicator even when content arrives quickly @@ -542,7 +544,52 @@ function ChatContent() { // Extend the loading state display time if content arrived too quickly const showExtendedLoading = showAiResponseLoading || // Normal loading state - (loadingStartTimeRef.current && loadingElapsedTime < LOADING_MIN_DISPLAY_MS && !hasCompletedAssistantReplyForLatestTurn); // Extended for minimum duration + (loadingStartTimeRef.current && loadingElapsedTime < LOADING_MIN_DISPLAY_MS && !hasCompletedAssistantReply); // Extended for minimum duration + + useEffect(() => { + if (!state.streaming && state.interactiveEvents.length === 0 && !showExtendedLoading) { + return; + } + + logger.info("[TRACE][RENDER][CHAT_SHELL]", { + sessionId: state.currentSessionId, + streamingActive: !!state.streaming?.isActive, + streamingMessageId: state.streaming?.messageId ?? null, + streamingContentLength: state.streaming?.content?.length ?? 0, + streamingReasoningLength: state.streaming?.reasoning?.length ?? 0, + streamingSteps: state.streaming?.steps?.length ?? 0, + streamingProgressEvents: state.streaming?.progressEvents?.length ?? 0, + streamingInteractiveEvents: state.streaming?.interactiveEvents?.length ?? 0, + interactiveEvents: state.interactiveEvents.length, + hasRenderableStreamingContent, + hasVisibleStreamingPayload, + showAiResponseLoading, + showExtendedLoading, + }); + console.info("[TRACE][RENDER][CHAT_SHELL]", { + sessionId: state.currentSessionId, + streamingActive: !!state.streaming?.isActive, + streamingMessageId: state.streaming?.messageId ?? null, + streamingContentLength: state.streaming?.content?.length ?? 0, + streamingReasoningLength: state.streaming?.reasoning?.length ?? 0, + streamingSteps: state.streaming?.steps?.length ?? 0, + streamingProgressEvents: state.streaming?.progressEvents?.length ?? 0, + streamingInteractiveEvents: state.streaming?.interactiveEvents?.length ?? 0, + interactiveEvents: state.interactiveEvents.length, + hasRenderableStreamingContent, + hasVisibleStreamingPayload, + showAiResponseLoading, + showExtendedLoading, + }); + }, [ + state.currentSessionId, + state.streaming, + state.interactiveEvents.length, + hasRenderableStreamingContent, + hasVisibleStreamingPayload, + showAiResponseLoading, + showExtendedLoading, + ]); // DEBUG: Log loading state calculation if (state.isProcessing || state.streaming?.isActive || showExtendedLoading) { @@ -552,7 +599,7 @@ function ChatContent() { currentSessionId: state.currentSessionId, processingSessionIds: state.processingSessionIds, isAiResponding, - hasCompletedAssistantReplyForLatestTurn, + hasCompletedAssistantReply, isCompacting: state.isCompacting, hasRenderableStreamingContent, hasAssistantText, @@ -587,32 +634,7 @@ function ChatContent() { const visibleStartIndex = isCompressed ? compactionDividerIndex : 0; const visibleMessages = (() => { const sliced = state.messages.slice(visibleStartIndex); - let lastUserIdx = -1; - for (let i = sliced.length - 1; i >= 0; i--) { - const role = sliced[i]?.role ?? sliced[i]?.info?.role; - if (role === "user") { - lastUserIdx = i; - break; - } - } - const hasEvtPrefix = (m: Message): boolean => { - const infoId = typeof m?.info?.id === "string" ? m.info.id : ""; - const topId = typeof m?.id === "string" ? m.id : ""; - return infoId.startsWith("evt_") || topId.startsWith("evt_"); - }; - const hasNonEvtAssistant = sliced.slice(lastUserIdx + 1).some( - (m) => - m?.role === "assistant" && - !hasEvtPrefix(m), - ); - // Guard: strip evt_-prefixed lifecycle-standby messages from the render - // list only when a non-evt assistant message exists for the same turn. - // Without this guard the evt_ entry is the only visible assistant content - // and must be shown. - if (!hasNonEvtAssistant) { - return sliced; - } - return sliced.filter((m) => !hasEvtPrefix(m)); + return sliced; })(); const hasCompatibilityWarnings = state.compatibilityWarnings.length > 0; const errorToasts = state.errorMessages; @@ -711,6 +733,16 @@ function ChatContent() { ) : null} + {/* Raw centralized SDK toast events are rendered here so the UI stays driven by the same event tape. */} + + {/* === LEFT: History sidebar overlay (hamburger-toggled, absolute positioned) === */} @@ -874,7 +906,7 @@ function ChatContent() { messageNode = null; } else { messageNode = ( - 0 && visibleMessages[visibleMessages.length - 1].role === "assistant" } + interactiveEvents={state.interactiveEvents} + messages={state.messages} + assistantTurnMessageId={state.assistantTurnMessageId} + currentSessionId={state.currentSessionId} + subagentsByParentMessageId={state.subagentsByParentMessageId} + subagentDetailsById={state.subagentDetailsById} + availableAgents={state.availableAgents} + todoItems={state.todoItems} /> {/* Loading status while processing before first stream payload */} diff --git a/webview/shared/src/chat/MessageComponents.tsx b/webview/shared/src/chat/MessageComponents.tsx index f4d448a..2c8f2cf 100644 --- a/webview/shared/src/chat/MessageComponents.tsx +++ b/webview/shared/src/chat/MessageComponents.tsx @@ -43,23 +43,29 @@ import { MarkdownRenderer } from "../components/MarkdownRenderer"; import { ActivityDiffExcerpt } from "./components/ActivityDiffExcerpt"; import { ImagePreviewModal } from "./ImagePreviewModal"; import { SubagentDetailModal } from "./SubagentDetailModal"; -import { DiffStats } from "./DiffStats"; -import { asString } from "./lib/messageHandler"; -import logger, { getGlobalShowBrowserConsole } from "./lib/logger"; -import { FILE_MENTION_REGEX } from "./PanelComponents"; - -import type { - ActivityDetail, - AppState, - InteractiveEvent, - Message, - MessagePart, - MessageStep, - Model, - ReasoningEvent, - StreamingState, - StreamingStep, - StructuredFileChange, +import { DiffStats } from "./DiffStats"; +import { asString } from "./lib/messageHandler"; +import { + getFinalAssistantResponseTextFromRawSdkEventPayloads, + getInteractiveEventsFromRawSdkEventPayloads, +} from "./lib/rawResponse"; +import logger, { getGlobalShowBrowserConsole } from "./lib/logger"; +import { FILE_MENTION_REGEX } from "./PanelComponents"; + +import type { + ActivityDetail, + AppState, + CentralizedDebugData, + CentralizedDebugSourceData, + InteractiveEvent, + Message, + MessagePart, + MessageStep, + Model, + ReasoningEvent, + StreamingState, + StreamingStep, + StructuredFileChange, SubagentDetail, SubagentSummary, TodoItem, @@ -181,6 +187,16 @@ function isUrl(path: string): boolean { return trimmed.startsWith("http://") || trimmed.startsWith("https://"); } +function isCallStyleActivityLabel(label: string): boolean { + const normalized = label.trim().toLowerCase(); + return ( + normalized.startsWith("call_") || + normalized === "background_task" || + normalized === "background task" || + normalized === "background-task" + ); +} + function isReasoningPart(part: MessagePart): boolean { const type = (part.type ?? "").toLowerCase(); return ( @@ -430,6 +446,401 @@ function getSubagentAccentTextStyle(id: string): CSSProperties { }; } +function debugFieldValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +function debugEntryKey(entry: unknown, preferredFields: string[]): string { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return `primitive:${String(entry)}`; + } + + const record = entry as Record; + const preferred = preferredFields + .map((field) => debugFieldValue(record[field])) + .filter(Boolean) + .join("|"); + if (preferred) { + return preferred; + } + + const fallbackFields = [ + "id", + "key", + "type", + "kind", + "label", + "title", + "status", + "messageID", + "partID", + "callID", + "messageId", + "partId", + "callId", + "text", + ]; + const fallback = fallbackFields + .map((field) => debugFieldValue(record[field])) + .filter(Boolean) + .join("|"); + if (fallback) { + return fallback; + } + + return Object.entries(record) + .slice(0, 6) + .map(([key, value]) => `${key}:${debugFieldValue(value)}`) + .join("|"); +} + +function dedupeDebugArray>( + items: unknown, + preferredFields: string[], +): T[] | undefined { + if (!Array.isArray(items)) { + return undefined; + } + + const seen = new Set(); + const deduped: T[] = []; + for (const entry of items) { + const key = debugEntryKey(entry, preferredFields); + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(entry as T); + } + + return deduped; +} + +function valuesHaveSameDebugShape(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (left === null || right === null) { + return false; + } + if (typeof left !== typeof right) { + return false; + } + if (typeof left !== "object") { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} + +const COMPACTABLE_DEBUG_INFO_FIELDS = new Set([ + "parentID", + "parentId", + "role", + "mode", + "agent", + "variant", + "path", + "cost", + "tokens", + "modelID", + "providerID", + "time", + "finish", + "id", + "sessionID", + "sessionId", +]); + +function objectHasMatchingSubset( + candidate: Record, + source: Record, +): boolean { + return Object.entries(candidate).every(([key, candidateValue]) => { + if (!Object.prototype.hasOwnProperty.call(source, key)) { + return false; + } + return valuesHaveSameDebugShape(candidateValue, source[key]); + }); +} + +function compactDuplicateDebugFields( + value: unknown, + seen = new WeakSet(), +): unknown { + if (Array.isArray(value)) { + if (seen.has(value)) { + return value; + } + seen.add(value); + return value.map((item) => compactDuplicateDebugFields(item, seen)); + } + if (!value || typeof value !== "object") { + return value; + } + if (seen.has(value as object)) { + return value; + } + seen.add(value as object); + + const next: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + next[key] = compactDuplicateDebugFields(child, seen); + } + + const infoValue = next.info; + if (infoValue && typeof infoValue === "object" && !Array.isArray(infoValue)) { + const infoRecord = infoValue as Record; + if ( + Object.keys(infoRecord).every((infoKey) => + COMPACTABLE_DEBUG_INFO_FIELDS.has(infoKey), + ) && + objectHasMatchingSubset(infoRecord, next) + ) { + delete next.info; + } + } + + return next; +} + +function normalizeToastTitle(title: string): string { + return title + .replace(/^[\s\u2022\u00b7\u25cf\u25cb\u25a1\u25aa\u25ab]+/u, "") + .trim(); +} + +function compactCentralizedRawSdkEventPayloadsForDebug( + rawSdkEventPayloads?: unknown[], +): unknown[] | undefined { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return undefined; + } + + const seen = new Set(); + const compacted: unknown[] = []; + + for (const entry of rawSdkEventPayloads) { + const rec = asRecord(entry); + if (!rec) { + compacted.push(entry); + continue; + } + + const eventType = asString(rec.type) ?? ""; + const source = asString(rec.source) ?? ""; + + let key = ""; + if (eventType === "tui.toast.show") { + const properties = asRecord(rec.properties); + key = [ + eventType, + normalizeToastTitle(asString(properties?.title) ?? ""), + asString(properties?.message) ?? "", + asString(properties?.variant) ?? "", + asString(rec.sessionId) ?? "", + ].join("|"); + } else if (eventType === "sync") { + const syncEvent = asRecord(rec.syncEvent); + const data = asRecord(syncEvent?.data); + const info = asRecord(data?.info); + const structured = asRecord(info?.structured); + const tokens = asRecord(info?.tokens); + key = [ + eventType, + asString(syncEvent?.type) ?? "", + asString(syncEvent?.aggregateID) ?? "", + asString(data?.sessionID) ?? "", + asString(info?.id) ?? "", + asString(info?.parentID) ?? "", + asString(info?.role) ?? "", + asString(info?.mode) ?? "", + asString(info?.agent) ?? "", + asString(info?.modelID) ?? "", + asString(info?.providerID) ?? "", + asString(info?.finish) ?? "", + asString(structured?.responseType) ?? "", + asString(structured?.message) ?? "", + typeof tokens?.total === "number" ? String(tokens.total) : "", + typeof tokens?.input === "number" ? String(tokens.input) : "", + typeof tokens?.output === "number" ? String(tokens.output) : "", + ].join("|"); + } else { + key = [ + eventType, + source, + asString(rec.id) ?? "", + asString(rec.sessionId) ?? "", + asString(rec.type) ?? "", + ].join("|"); + } + + if (seen.has(key)) { + continue; + } + seen.add(key); + compacted.push(entry); + } + + return compacted; +} + +function sanitizeStructuredOutputForDebug( + value: unknown, + depth = 0, +): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeStructuredOutputForDebug(item, depth)); + } + if (!value || typeof value !== "object") { + return value; + } + + const next: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + if (key === "raw" && depth >= 1) { + continue; + } + next[key] = sanitizeStructuredOutputForDebug(child, key === "raw" ? depth + 1 : depth); + } + + return next; +} + +function arraysHaveSameDebugKeys( + left: unknown[], + right: unknown[], + preferredFields: string[], +): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((entry, index) => { + const leftKey = debugEntryKey(entry, preferredFields); + const rightKey = debugEntryKey(right[index], preferredFields); + return leftKey === rightKey; + }); +} + +function compactDebugSubagent(subagent: Record): Record { + const next: Record = { ...subagent }; + const references = dedupeDebugArray>( + subagent.references, + ["messageID", "partID", "callID"], + ); + if (references) { + next.references = references; + } + + const thinkingEvents = dedupeDebugArray>( + subagent.thinkingEvents, + ["id", "key", "type", "kind", "label", "title", "text", "createdAt"], + ); + if (thinkingEvents) { + next.thinkingEvents = thinkingEvents; + } + + const conversationEvents = dedupeDebugArray>( + subagent.conversationEvents, + ["id", "key", "type", "kind", "label", "title", "messageID", "partID", "callID", "text"], + ); + if (conversationEvents) { + next.conversationEvents = conversationEvents; + } + + const progressEvents = dedupeDebugArray>( + subagent.progressEvents, + ["id", "key", "type", "kind", "label", "title", "messageID", "partID", "callID"], + ); + if (progressEvents) { + next.progressEvents = progressEvents; + } + + const timelineEvents = dedupeDebugArray>( + subagent.timelineEvents, + ["id", "key", "type", "kind", "label", "title", "messageID", "partID", "callID"], + ); + if (timelineEvents) { + next.timelineEvents = timelineEvents; + } + + return next; +} + +function compactDebugTimeline(value: Record): Record { + const next: Record = { ...value }; + const timelineKeyFields = [ + "id", + "callID", + "title", + "status", + "type", + "partType", + "source", + ]; + const steps = dedupeDebugArray>( + value.steps, + timelineKeyFields, + ); + const progressEvents = dedupeDebugArray>( + value.progressEvents, + timelineKeyFields, + ); + + if ( + steps && + progressEvents && + arraysHaveSameDebugKeys(steps, progressEvents, timelineKeyFields) + ) { + next.steps = steps; + delete next.progressEvents; + } else { + if (steps) { + next.steps = steps; + } + if (progressEvents) { + next.progressEvents = progressEvents; + } + } + + const interactiveEvents = dedupeDebugArray>( + value.interactiveEvents, + ["id", "key", "type", "label", "title", "status", "value"], + ); + if (interactiveEvents) { + next.interactiveEvents = interactiveEvents; + } + + const reasoningEvents = dedupeDebugArray>( + value.reasoningEvents, + ["id", "key", "type", "title", "text"], + ); + if (reasoningEvents) { + next.reasoningEvents = reasoningEvents; + } + + const subagents = dedupeDebugArray>(value.subagents, ["id"]); + if (subagents) { + next.subagents = subagents.map((subagent) => compactDebugSubagent(subagent)); + } + + return next; +} + const SEARCH_LABELS = new Set(["grep", "search", "glob", "ripgrep", "ast-grep", "find"]); function buildSearchPattern(...values: Array): string { @@ -682,7 +1093,7 @@ function formatMessageTime(timestamp?: number): string | null { }); } -function getMessageTimestamp(message?: Message): number | undefined { +function getMessageTimestamp(message?: Message): number | undefined { if (!message) return undefined; const rec = message as Record; const info = rec.info as Record | undefined; @@ -707,27 +1118,35 @@ function getMessageTimestamp(message?: Message): number | undefined { return parsed; } } - } - return undefined; -} - -function messageBodyFromParts(parts?: MessagePart[]): string { - if (!parts) { - return ""; - } - return parts - .map((part) => { - if (!isRenderableAssistantTextPart(part)) { - return ""; - } - return (part.message ?? part.text ?? part.content ?? "").trim(); - }) - .filter((partText) => partText.length > 0) - .join("\n\n") - .trim(); -} - -function interactiveChoiceTextsFromMessage(message?: Message): string[] { + } + return undefined; +} + +function messageBodyFromParts( + parts?: Array | null | undefined>, +): string { + if (!parts) { + return ""; + } + return parts + .map((part) => { + const partRec = asRecord(part); + if (!partRec || !isRenderableAssistantTextPart(partRec as MessagePart)) { + return ""; + } + return ( + (partRec.message as string | undefined) ?? + (partRec.text as string | undefined) ?? + (partRec.content as string | undefined) ?? + "" + ).trim(); + }) + .filter((partText) => partText.length > 0) + .join("\n\n") + .trim(); +} + +function interactiveChoiceTextsFromMessage(message?: Message): string[] { if (!message) { return []; } @@ -827,11 +1246,11 @@ function looksLikeFlattenedInteractiveEcho( return matches >= 2; } -function asRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null - ? (value as Record) - : null; -} +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} function firstNonEmptyString(...values: unknown[]): string | undefined { for (const value of values) { @@ -982,95 +1401,9 @@ function formatQuestionPromptForAssistant( return trimmed; } -function questionPromptFromMessage(message?: Message): string | undefined { - if (!message) { - return undefined; - } - - const messageRec = asRecord(message); - const infoRec = asRecord(messageRec?.info); - const structured = - asRecord(messageRec?.structuredOutput) || - asRecord(messageRec?.structured_output) || - asRecord(messageRec?.structured) || - asRecord(infoRec?.structuredOutput) || - asRecord(infoRec?.structured_output) || - asRecord(infoRec?.structured); - const question = asRecord(structured?.question); - - // displayPrompt is the primary source-of-truth for question chat bubble text. - const explicitDisplayPrompt = firstNonEmptyString( - question?.displayPrompt, - question?.assistantPrompt, - question?.responseMessage, - ); - if (explicitDisplayPrompt) { - return explicitDisplayPrompt; - } - - if (question) { - const questionType = firstNonEmptyString(question.type)?.toLowerCase(); - if (questionType === "message") { - return firstNonEmptyString( - question.message, - question.content, - question.question, - question.title, - ); - } - if (questionType === "quick_actions" || questionType === "quick-actions") { - const prompt = firstNonEmptyString( - question.question, - question.title, - question.message, - question.content, - ); - return prompt - ? formatQuestionPromptForAssistant(prompt, "quick_actions") - : undefined; - } - const prompt = firstNonEmptyString( - question.question, - question.message, - question.content, - question.title, - ); - if (prompt) { - return formatQuestionPromptForAssistant( - prompt, - questionType === "confirm" ? "confirm" : "question", - ); - } - } - - if (Array.isArray(message.interactiveEvents)) { - for (const event of message.interactiveEvents) { - if (event.type === "question" || event.type === "confirm") { - const prompt = firstNonEmptyString(event.question, event.title); - if (prompt) { - return formatQuestionPromptForAssistant(prompt, event.type); - } - } - if (event.type === "message") { - const prompt = firstNonEmptyString(event.message, event.title); - if (prompt) { - return prompt; - } - } - if (event.type === "quick_actions") { - const prompt = firstNonEmptyString(event.title); - if (prompt) { - return formatQuestionPromptForAssistant(prompt, "quick_actions"); - } - } - } - } - return undefined; -} - -function questionPromptFromInteractiveEvents( - events?: InteractiveEvent[], -): string | undefined { +function questionPromptFromInteractiveEvents( + events?: InteractiveEvent[], +): string | undefined { if (!Array.isArray(events) || events.length === 0) { return undefined; } @@ -1109,7 +1442,7 @@ function questionPromptFromInteractiveEvents( return undefined; } -function hasQuestionLikeInteractiveContent(message?: Message): boolean { +function hasQuestionLikeInteractiveContent(message?: Message): boolean { if (!message) { return false; } @@ -1140,7 +1473,7 @@ function hasQuestionLikeInteractiveContent(message?: Message): boolean { return false; } - return message.interactiveEvents.some((event) => { + return message.interactiveEvents.some((event) => { const type = firstNonEmptyString(asRecord(event)?.type)?.toLowerCase(); return ( type === "question" || @@ -1148,8 +1481,31 @@ function hasQuestionLikeInteractiveContent(message?: Message): boolean { type === "quick_actions" || type === "quick-actions" ); - }); -} + }); +} + +function rawMessagePartsFromRawSdkEventPayloads( + rawSdkEventPayloads?: unknown[], +): MessagePart[] { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return []; + } + + const parts: MessagePart[] = []; + for (const payload of rawSdkEventPayloads) { + const eventRec = asRecord(payload); + if (!eventRec || asString(eventRec.type) !== "message.part.updated") { + continue; + } + const propertiesRec = asRecord(eventRec.properties); + const partRec = asRecord(propertiesRec?.part) ?? asRecord(eventRec.part); + if (partRec) { + parts.push(partRec as MessagePart); + } + } + + return parts; +} function summaryText(message?: Message): string { @@ -1202,7 +1558,7 @@ function modelLabel(message?: Message): string { * * *** stream.content is never used *** — it carries transitional text that * leaks reasoning/planning content into the response card. The response body - * always derives from message fields (content / text / partsBody / summary). + * always derives from the raw SDK response helper plus prompt-aware guards. * * This is one layer of a three-layer defense against reasoning leaks: * 1. Extension — StructuredOutputProcessor populates message.content from @@ -1210,110 +1566,337 @@ function modelLabel(message?: Message): string { * 2. Webview — getMessageContent ignores stream.content; always uses message. * 3. Webview — showResponseBody hides the markdown body during live streaming. */ -function getMessageContent( - message?: Message, - streaming?: StreamingState, -): string { - if (streaming) { - if (!message) return ""; - } - - if (!message) { - return ""; - } - const partsBody = messageBodyFromParts(message.parts); - const baseContent = - firstNonEmptyString( - message.content, - message.text, - partsBody, - summaryText(message), - ) ?? ""; - const questionPrompt = questionPromptFromMessage(message); - const messageResponseType = firstNonEmptyString( - message.responseType, - asString(asRecord(message)?.structuredOutput?.responseType), - asString(asRecord(message)?.structured?.responseType), - )?.toLowerCase(); - if (!questionPrompt) { - return baseContent; - } - - if ( - (messageResponseType === "progress" || messageResponseType === "question") && - hasQuestionLikeInteractiveContent(message) - ) { - return questionPrompt; - } - - if (!baseContent || isLowValueInteractiveBodyText(baseContent)) { - return questionPrompt; - } - - const promptNorm = normalizeComparableText(questionPrompt); - const bodyNorm = normalizeComparableText(baseContent); - if ( - promptNorm && - bodyNorm && - (bodyNorm === promptNorm || - bodyNorm.includes(promptNorm) || - promptNorm.includes(bodyNorm)) - ) { - return questionPrompt; - } - - return baseContent; -} +function getMessageContent( + rawSdkEventPayloads?: unknown[], +): string { + const baseContent = getFinalAssistantResponseTextFromRawSdkEventPayloads( + rawSdkEventPayloads, + ); + if (!baseContent) { + return ""; + } + + const questionPrompt = questionPromptFromInteractiveEvents( + getInteractiveEventsFromRawSdkEventPayloads(rawSdkEventPayloads), + ); + if (!questionPrompt) { + return baseContent; + } + + if (isLowValueInteractiveBodyText(baseContent)) { + return questionPrompt; + } + + const promptNorm = normalizeComparableText(questionPrompt); + const bodyNorm = normalizeComparableText(baseContent); + if ( + promptNorm && + bodyNorm && + (bodyNorm === promptNorm || + bodyNorm.includes(promptNorm) || + promptNorm.includes(bodyNorm)) + ) { + return questionPrompt; + } + + return baseContent; +} type RawDebugParseStatus = "parsed" | "empty" | "unparseable" | "truncated"; -type ParsedRawDebugForUi = { - status: RawDebugParseStatus; - parts: Array>; -}; - -function parseRawResponseDebugForUi(raw: unknown): ParsedRawDebugForUi { - if (typeof raw === "undefined" || raw === null) { - return { status: "empty", parts: [] }; - } - if (typeof raw === "object") { - const rec = asRecord(raw); - const parts = Array.isArray(rec?.parts) - ? rec.parts - .map((part) => asRecord(part)) - .filter((part): part is Record => !!part) - : []; - return { status: "parsed", parts }; - } - if (typeof raw !== "string") { - return { status: "unparseable", parts: [] }; - } - const text = raw.trim(); - if (!text) { - return { status: "empty", parts: [] }; - } - const truncMatch = text.match(/\.\.\.\s*$/i); - const candidate = truncMatch ? text.slice(0, truncMatch.index).trim() : text; - try { - const parsed = JSON.parse(candidate); - const rec = asRecord(parsed); - const parts = Array.isArray(rec?.parts) - ? rec.parts - .map((part) => asRecord(part)) - .filter((part): part is Record => !!part) - : []; - return { status: truncMatch ? "truncated" : "parsed", parts }; - } catch { - return { status: truncMatch ? "truncated" : "unparseable", parts: [] }; - } -} - -type ThoughtItem = { key: string; text: string }; -type ProgressItem = { - key: string; - mergeKey: string; - id?: string; - callID?: string; +type ParsedRawDebugForUi = { + status: RawDebugParseStatus; + parts: Array>; +}; + +type ParsedRawResponseRecordForUi = { + status: RawDebugParseStatus; + record: Record | null; +}; + +function parseRawResponseRecordForUi(raw: Message["rawResponse"]): ParsedRawResponseRecordForUi { + if (typeof raw === "object" && raw !== null) { + return { status: "parsed", record: asRecord(raw) }; + } + if (typeof raw !== "string") { + return { status: "empty", record: null }; + } + const text = raw.trim(); + if (!text) { + return { status: "empty", record: null }; + } + const truncMatch = text.match(/\.\.\.\s*$/i); + const candidate = truncMatch ? text.slice(0, truncMatch.index).trim() : text; + try { + return { + status: truncMatch ? "truncated" : "parsed", + record: asRecord(JSON.parse(candidate)), + }; + } catch { + return { status: truncMatch ? "truncated" : "unparseable", record: null }; + } +} + +function parseRawResponseDebugForUi(raw: Message["rawResponse"]): ParsedRawDebugForUi { + const parsed = parseRawResponseRecordForUi(raw); + if (!parsed.record) { + return { status: parsed.status, parts: [] }; + } + const parts = Array.isArray(parsed.record.parts) + ? parsed.record.parts + .map((part) => asRecord(part)) + .filter((part): part is Record => !!part) + : []; + return { status: parsed.status, parts }; +} + +function rawResponseInfoRecordForUi( + raw: Message["rawResponse"], +): Record | null { + const normalized = parseRawResponseRecordForUi(raw).record; + if (!normalized) { + return null; + } + + const nestedPaths = [ + ["info"], + ["payload", "syncEvent", "data", "info"], + ["payload", "data", "info"], + ["data", "info"], + ]; + + for (const path of nestedPaths) { + let current: unknown = normalized; + let valid = true; + for (const segment of path) { + const record = asRecord(current); + if (!record) { + valid = false; + break; + } + current = record[segment]; + } + if (valid) { + const infoRecord = asRecord(current); + if (infoRecord) { + return infoRecord; + } + } + } + + return null; +} + +// LEGACY BRIDGE: temporary helper kept only while we are migrating the +// conversation UI to render directly from the centralized raw event tape. +// This path will be removed once the new architecture owns the full ordering +// and system-message rendering contract. +function extractSystemMessageTextFromRawEventStream( + rawSdkEventPayloads?: unknown[], +): string | undefined { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return undefined; + } + + for (let index = rawSdkEventPayloads.length - 1; index >= 0; index -= 1) { + const event = asRecord(rawSdkEventPayloads[index]); + if (!event) { + continue; + } + + if (String(event.type ?? "").trim() !== "message.part.updated") { + continue; + } + + const structured = asRecord(event.structured); + if (String(structured?.kind ?? "").trim() !== "message") { + continue; + } + + const properties = asRecord(event.properties); + const part = asRecord(properties?.part); + const text = + firstNonEmptyString( + structured?.text, + structured?.message, + part?.text, + part?.message, + event.text, + event.message, + ) ?? ""; + if (text.trim().length > 0) { + return text; + } + } + + return undefined; +} + +type SessionUpdatedMetadata = { + sessionID?: string; + title?: string; + agent?: string; + modelID?: string; + providerID?: string; + variant?: string; + version?: string; + directory?: string; +}; + +function extractSessionUpdatedMetadataFromRawEventStream( + rawSdkEventPayloads?: unknown[], +): SessionUpdatedMetadata | undefined { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return undefined; + } + + for (let index = rawSdkEventPayloads.length - 1; index >= 0; index -= 1) { + const event = asRecord(rawSdkEventPayloads[index]); + if (!event || String(event.type ?? "").trim() !== "session.updated") { + continue; + } + + const properties = asRecord(event.properties); + const info = asRecord(properties?.info); + const model = asRecord(info?.model); + + return { + sessionID: + firstNonEmptyString( + properties?.sessionID, + event.sessionId, + event.sessionID, + info?.id, + ) ?? undefined, + title: firstNonEmptyString(info?.title) ?? undefined, + agent: firstNonEmptyString(info?.agent) ?? undefined, + modelID: + firstNonEmptyString( + model?.id, + model?.modelID, + info?.modelID, + ) ?? undefined, + providerID: + firstNonEmptyString( + model?.providerID, + info?.providerID, + ) ?? undefined, + variant: + firstNonEmptyString(model?.variant, info?.variant) ?? undefined, + version: firstNonEmptyString(info?.version) ?? undefined, + directory: firstNonEmptyString(info?.directory) ?? undefined, + }; + } + + return undefined; +} + +function stringifyDebugValue(value: unknown): string { + const seen = new WeakSet(); + const replacer = (_key: string, nextValue: unknown) => { + if (nextValue === undefined) return "(undefined)"; + if (typeof nextValue === "function") return "(function)"; + if (nextValue instanceof Error) return { message: nextValue.message, name: nextValue.name }; + if (typeof nextValue === "object" && nextValue !== null) { + if (seen.has(nextValue)) return "(circular)"; + seen.add(nextValue); + } + return nextValue; + }; + + try { + const normalized = + typeof value === "string" ? normalizeDebugStringForDisplay(value) : value; + return JSON.stringify(normalized, replacer, 2) ?? ""; + } catch { + return String(value); + } +} + +function normalizeDebugStringForDisplay(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) { + return value; + } + if (!/^[{\[]/.test(trimmed)) { + return value; + } + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +type DebugObjectViewProps = { + value: unknown; +}; + +// Keep debug output in object-literal form so the payload stays readable and +// visually matches the raw SDK shape instead of a JSON string dump. +function formatDebugObjectLiteral(value: unknown, depth = 0, seen = new WeakSet()): string { + const indent = " ".repeat(depth); + const nextIndent = " ".repeat(depth + 1); + + if (value === null) return "null"; + if (typeof value === "string") { + const trimmed = value.trim(); + if (/^[{\[]/.test(trimmed)) { + try { + return formatDebugObjectLiteral(JSON.parse(trimmed), depth, seen); + } catch { + // Fall through to a quoted string when the payload only looks like JSON. + } + } + return JSON.stringify(value); + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (typeof value === "undefined") return "undefined"; + if (typeof value === "symbol") return value.toString(); + if (typeof value === "function") return "[Function]"; + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (value instanceof Error) { + return `{\n${nextIndent}name: ${JSON.stringify(value.name)},\n${nextIndent}message: ${JSON.stringify(value.message)}\n${indent}}`; + } + + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + const items = value.map((item) => `${nextIndent}${formatDebugObjectLiteral(item, depth + 1, seen)}`); + return `[\n${items.join(",\n")}\n${indent}]`; + } + + if (typeof value === "object") { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + + const entries = Object.entries(value); + if (entries.length === 0) return "{}"; + + const body = entries + .map(([key, nextValue]) => `${nextIndent}${key}: ${formatDebugObjectLiteral(nextValue, depth + 1, seen)}`) + .join(",\n"); + return `{\n${body}\n${indent}}`; + } + + return String(value); +} + +function DebugObjectView({ value }: DebugObjectViewProps) { + return ( +
+      {formatDebugObjectLiteral(value)}
+    
+ ); +} + +type ThoughtItem = { key: string; text: string }; +type ProgressItem = { + key: string; + mergeKey: string; + id?: string; + callID?: string; title: string; status: "pending" | "done" | "error"; source?: "stream" | "final" | "raw_debug"; @@ -1322,10 +1905,10 @@ type ProgressItem = { meta?: string; filePath?: string; diffStats?: { added: number; deleted: number }; - activityDetail?: ActivityDetail; - /** Arrival-order sequence number from StreamingStep.streamSeq or MessageStep.streamSeq */ - streamSeq?: number; -}; + activityDetail?: ActivityDetail; + /** Arrival-order sequence number from StreamingStep.streamSeq or MessageStep.streamSeq */ + streamSeq?: number; +}; type ThinkingBlock = { kind: "thinking"; items: ThoughtItem[] }; type StepsBlock = { kind: "steps"; items: ProgressItem[] }; @@ -1338,92 +1921,205 @@ type TimelineBlock = ThinkingBlock | StepsBlock | ContentBlock; * persisted reasoningEvent keys ("evt-{createdAt}"). * Returns 0 for parts-based keys ("part-{idx}") that have no timestamp. */ -function seqFromThoughtKey(key: string): number { - const evtMatch = key.match(/^evt-(\d+)$/); - if (evtMatch) return parseInt(evtMatch[1], 10); - const streamMatch = key.match(/stream-\d+-(\d+)/); - if (streamMatch) return parseInt(streamMatch[1], 10); - return 0; -} - -function thoughtItemsFromMessage(message?: Message): ThoughtItem[] { - const items: ThoughtItem[] = []; - const seen = new Set(); - const pushUnique = (key: string, text: string) => { - const cleaned = text.trim(); - if (!cleaned) return; +function seqFromThoughtKey(key: string): number { + const evtMatch = key.match(/^evt-(\d+)/); + if (evtMatch) return parseInt(evtMatch[1], 10); + const streamMatch = key.match(/stream-\d+-(\d+)/); + if (streamMatch) return parseInt(streamMatch[1], 10); + return 0; +} + +function thoughtItemsFromRawEventPayloads( + rawSdkEventPayloads?: unknown[], +): ThoughtItem[] { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return []; + } + + const items: ThoughtItem[] = []; + const seen = new Set(); + const pushUnique = (key: string, text: string) => { + const cleaned = text.trim(); + if (!cleaned) return; const fp = normalizeComparableText(cleaned); if (!fp || seen.has(fp)) return; seen.add(fp); items.push({ key, text: cleaned }); }; - if (Array.isArray(message?.reasoningPayload?.events)) { - message.reasoningPayload.events.forEach((event, index) => { - pushUnique(`evt-${event.createdAt}-${index}`, event.text || ""); - }); - } - - if (Array.isArray(message?.reasoningEvents)) { - message.reasoningEvents.forEach((event: ReasoningEvent, index: number) => { - pushUnique(`evt-${event.createdAt}-${index}`, event.text || ""); - }); - } - - if (items.length > 0) { - return items; - } - - // Do not derive visible thinking text from raw reasoning parts. - // Some providers include internal instruction/planning traces there. - return []; -} - -function thoughtItemsFromStreaming(streaming?: StreamingState): ThoughtItem[] { - if (!streaming) { - return []; - } - const mergedReasoning = (streaming.reasoning || "").trim(); - if (mergedReasoning.length > 0) { - return [ - { - key: "stream-merged-reasoning", - text: mergedReasoning, - }, - ]; - } - if (streaming.reasoningEvents && streaming.reasoningEvents.length > 0) { - const fromEvents = streaming.reasoningEvents - .filter((event: ReasoningEvent) => { - return event.text && event.text.length > 0; - }) - .map((event: ReasoningEvent, idx: number) => ({ - key: `stream-${idx}-${event.createdAt}`, - text: event.text.trim(), - })); - if (fromEvents.length > 0) { - return fromEvents; - } - } - - // Fall back to streaming content as a thinking step so the activity - // timeline shows ongoing work even when no explicit reasoning is emitted. - const contentText = (streaming.content || "").trim(); - if (contentText.length > 0) { - return [ - { - key: "stream-content-as-thinking", - text: contentText, - }, - ]; - } - - return []; -} - -function normalizeProgressStatus( - value?: string | null, -): "pending" | "done" | "error" { + for (let index = 0; index < rawSdkEventPayloads.length; index += 1) { + const event = asRecord(rawSdkEventPayloads[index]); + if (!event || String(event.type ?? "").trim() !== "message.part.updated") { + continue; + } + + const structured = asRecord(event.structured); + if (String(structured?.kind ?? "").trim() !== "thinking") { + continue; + } + + const properties = asRecord(event.properties); + const part = asRecord(properties?.part); + const text = firstNonEmptyString( + asString(part?.text), + asString(part?.message), + asString(structured?.text), + asString(structured?.message), + asString(event.text), + asString(event.message), + ); + if (!text) { + continue; + } + + const partTime = asRecord(part?.time); + const createdAt = + (typeof partTime?.end === "number" ? partTime.end : undefined) ?? + (typeof partTime?.start === "number" ? partTime.start : undefined) ?? + (typeof properties?.time === "number" ? properties.time : undefined) ?? + (typeof event.time === "number" ? event.time : undefined) ?? + index; + pushUnique(`evt-${createdAt}-${index}`, text); + } + + return items; +} + +function progressItemsFromRawEventPayloads( + rawSdkEventPayloads?: unknown[], +): ProgressItem[] { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return []; + } + + const rawSteps: Array = []; + + for (let index = 0; index < rawSdkEventPayloads.length; index += 1) { + const event = asRecord(rawSdkEventPayloads[index]); + if (!event || String(event.type ?? "").trim() !== "message.part.updated") { + continue; + } + + const structured = asRecord(event.structured); + if (String(structured?.kind ?? "").trim() === "thinking") { + continue; + } + + const properties = asRecord(event.properties); + const part = asRecord(properties?.part) ?? asRecord(event.part); + if (!part) { + continue; + } + + const state = asRecord(part.state); + const input = asRecord(state?.input) || asRecord(part.input) || asRecord(part.arguments); + const metadata = asRecord(state?.metadata) || asRecord(part.metadata); + const tool = firstNonEmptyString( + asString(part.tool), + asString(part.name), + asString(part.type), + )?.toLowerCase(); + const partType = firstNonEmptyString( + asString(part.type), + asString(part.partType), + asString(structured?.kind), + ); + const status = normalizeProgressStatus( + firstNonEmptyString( + asString(state?.status), + asString(part.status), + asString(structured?.status), + ), + ); + const id = firstNonEmptyString( + asString(part.id), + asString(part.partID), + asString(part.partId), + asString(properties?.partID), + asString(properties?.partId), + asString(event.id), + ); + const callID = firstNonEmptyString(asString(part.callID), asString(part.callId)); + const filePath = firstNonEmptyString( + asString(input?.filePath), + asString(input?.path), + asString(input?.file), + asString(part.filePath), + asString(part.file), + asString(part.path), + ); + const output = firstNonEmptyString( + asString(state?.output), + asString(part.output), + asString(part.text), + asString(structured?.text), + asString(event.text), + asString(event.message), + ); + const preview = firstNonEmptyString(asString(metadata?.preview)); + + const compactMetadata: Record = {}; + for (const [key, value] of Object.entries(metadata ?? {})) { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + compactMetadata[key] = value; + } + } + + const title = + firstNonEmptyString( + asString(part.title), + tool, + partType, + asString(structured?.eventType), + asString(event.type), + ) || "step"; + + const hasRenderableProgress = + !!callID || + !!id || + !!filePath || + !!output || + !!preview || + !!tool || + !!partType; + if (!hasRenderableProgress) { + continue; + } + + rawSteps.push({ + id, + callID, + title, + type: tool ? "tool" : "step", + status, + source: "raw_debug", + partType: partType || undefined, + internal: Boolean(part.internal), + meta: preview || undefined, + filePath, + streamSeq: index, + activityDetail: { + kind: tool === "read" ? "read" : tool === "question" ? "other" : "tool_call", + summary: filePath || preview || output || title, + tool, + file: filePath, + input: input ?? undefined, + output: output || undefined, + metadata: Object.keys(compactMetadata).length > 0 ? compactMetadata : undefined, + }, + }); + } + + return progressItemsFromSteps(rawSteps, "raw-event"); +} + +function normalizeProgressStatus( + value?: string | null, +): "pending" | "done" | "error" { const v = value?.toLowerCase(); if ( v === "done" || @@ -1509,10 +2205,10 @@ function isActionProgressStep(step: MessageStep | StreamingStep): boolean { return true; } -function progressItemsFromSteps( - steps: Array, - prefix: string, -): ProgressItem[] { +function progressItemsFromSteps( + steps: Array, + prefix: string, +): ProgressItem[] { const stepMap = new Map(); steps @@ -1600,58 +2296,171 @@ function progressItemsFromSteps( } }); - return Array.from(stepMap.values()); -} - -function progressItemsFromMessage(message?: Message): ProgressItem[] { - if (!message) { - return []; - } - let items: ProgressItem[] = []; - if (Array.isArray(message.steps) && message.steps.length > 0) { - items = progressItemsFromSteps(message.steps, "msg-steps"); - } else if ( - Array.isArray(message.progressEvents) && - message.progressEvents.length > 0 - ) { - items = progressItemsFromSteps( - message.progressEvents, - "msg-progress-events", - ); - } - - // For completed messages, any hanging pending steps should be marked as done - for (const item of items) { - if (item.status === "pending") { - item.status = "done"; - } - } - - return items; -} - -function progressItemsFromStreaming( - streaming?: StreamingState, -): ProgressItem[] { - if (!streaming) { - return []; - } - if (Array.isArray(streaming.steps) && streaming.steps.length > 0) { - return progressItemsFromSteps(streaming.steps, "stream-steps"); - } - if ( - Array.isArray(streaming.progressEvents) && - streaming.progressEvents.length > 0 - ) { - return progressItemsFromSteps( - streaming.progressEvents, - "stream-progress-events", - ); - } - return []; -} - -function formatTodoStatus(status: TodoItem["status"]): string { + return Array.from(stepMap.values()); +} + +function progressItemsFromRawResponseParts( + rawResponse?: Message["rawResponse"], +): ProgressItem[] { + if (!rawResponse) { + return []; + } + + const parseRawResponseRecord = (raw: unknown): Record | null => { + const rec = asRecord(raw); + if (rec) { + return rec; + } + if (typeof raw !== "string") { + return null; + } + const text = raw.trim(); + if (!text) { + return null; + } + try { + return asRecord(JSON.parse(text)); + } catch { + return null; + } + }; + + const rawResponseRec = parseRawResponseRecord(rawResponse); + const rawParts = Array.isArray(rawResponseRec?.parts) ? rawResponseRec.parts : []; + if (rawParts.length === 0) { + return []; + } + + const items: ProgressItem[] = []; + for (const [index, part] of rawParts.entries()) { + const partRec = asRecord(part); + if (!partRec) { + continue; + } + + const toolName = firstNonEmptyString( + asString(partRec.tool), + asString(partRec.name), + asString(partRec.type), + )?.toLowerCase(); + const stateRec = asRecord(partRec.state); + const inputRec = asRecord(stateRec?.input); + const metadataRec = asRecord(stateRec?.metadata); + const filePath = firstNonEmptyString( + asString(inputRec?.filePath), + asString(inputRec?.path), + asString(inputRec?.file), + ); + const status = normalizeProgressStatus( + firstNonEmptyString( + asString(stateRec?.status), + asString(partRec.status), + ), + ); + const callID = firstNonEmptyString( + asString(partRec.callID), + asString(partRec.callId), + ); + const id = firstNonEmptyString( + asString(partRec.id), + asString(partRec.partID), + asString(partRec.partId), + ); + + if (toolName !== "read" && !callID && !id) { + continue; + } + + const output = firstNonEmptyString( + asString(stateRec?.output), + asString(partRec.output), + ); + const preview = firstNonEmptyString(asString(metadataRec?.preview)); + const rawTitle = toolName || "step"; + + const compactMetadata: Record = {}; + for (const [key, value] of Object.entries(metadataRec ?? {})) { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + compactMetadata[key] = value; + } + } + + items.push({ + key: `raw-${callID ?? id ?? partRec.messageID ?? index}`, + mergeKey: callID ? `call:${callID}` : id ? `id:${id}` : `index:${index}`, + id, + callID, + title: rawTitle, + status, + source: "raw_debug", + partType: asString(partRec.type) || "tool", + internal: Boolean(partRec.internal), + meta: preview || undefined, + filePath, + diffStats: undefined, + activityDetail: { + kind: toolName === "read" ? "read" : "tool_call", + summary: filePath || preview || rawTitle, + tool: toolName, + file: filePath, + input: inputRec ?? undefined, + output: output || undefined, + metadata: Object.keys(compactMetadata).length > 0 ? compactMetadata : undefined, + }, + streamSeq: index, + }); + } + + return items; +} + +function progressItemsFromCentralizedData( + rawSdkEventPayloads?: unknown[], +): ProgressItem[] { + const rawItems = progressItemsFromRawEventPayloads(rawSdkEventPayloads); + const stepMap = new Map(); + + for (const item of rawItems) { + const mergeKey = item.mergeKey || item.callID || item.id || item.key; + const existing = stepMap.get(mergeKey); + if (!existing) { + stepMap.set(mergeKey, { ...item }); + continue; + } + + if ( + item.status === "error" || + item.status === "done" || + existing.status === "pending" + ) { + existing.status = item.status; + } + if (item.title) existing.title = item.title; + if (item.meta) existing.meta = item.meta; + if (item.filePath) existing.filePath = item.filePath; + if (item.id && !existing.id) existing.id = item.id; + if (item.callID && !existing.callID) existing.callID = item.callID; + if (item.source && !existing.source) existing.source = item.source; + if (item.partType && !existing.partType) existing.partType = item.partType; + existing.internal = Boolean(existing.internal || item.internal); + if (item.diffStats) existing.diffStats = item.diffStats; + if (item.activityDetail) existing.activityDetail = item.activityDetail; + } + + const items = Array.from(stepMap.values()); + for (const item of items) { + if (item.status === "pending") { + item.status = "done"; + } + } + return items; +} + +function formatTodoStatus(status: TodoItem["status"]): string { switch (status) { case "in_progress": return "in progress"; @@ -2428,19 +3237,19 @@ function messageOwnsChangeSummary( return ownerIds.some((id) => id.trim() === summaryMessageId); } -/** - * Single source of truth for building the Activity timeline. - * - * Used for BOTH streaming and completed (persisted) messages. The timeline is - * built by sorting thoughtItems (by createdAt timestamp from their key) and - * progressItems (by streamSeq) into arrival order, then grouping consecutive - * same-kind entries so all steps merge into one StepsBlock and all thinking - * items merge into one ThinkingBlock. - * - * Falls back to a parts-based layout for server-loaded messages where timing - * data is absent (no streamSeq on steps, no createdAt on thoughts). - */ -function buildTimeline( +/** + * LEGACY NORMALIZATION LAYER. + * + * This timeline builder is still in place so the current UI can keep working + * during the migration, but it is not the long-term architecture. The new + * conversation flow should render directly from the centralized raw event tape + * and delete this re-sorting / grouping pass once the v2 path is complete. + * + * For now it still handles both streaming and hydrated messages by sorting + * thoughtItems and progressItems, then falling back to parts-based replay when + * timing data is missing. + */ +function buildTimeline( thoughtItems: ThoughtItem[], progressItems: ProgressItem[], html: string, @@ -2594,33 +3403,25 @@ function buildTimeline( return blocks; } -function buildMessageTimeline( - message?: Message, - html = "", -): TimelineBlock[] { - return buildTimeline( - thoughtItemsFromMessage(message), - progressItemsFromMessage(message), - html, - message?.parts, - ); -} - -function buildStreamingTimeline( - streaming?: StreamingState, - html = "", -): TimelineBlock[] { - return buildTimeline( - thoughtItemsFromStreaming(streaming), - progressItemsFromStreaming(streaming), - html, - ); -} - - - - -const MAX_VISIBLE_COMPLETED_ACTIVITY = 5; +function buildMessageTimeline( + message?: Message, + html = "", + rawSdkEventPayloads?: unknown[], +): TimelineBlock[] { + const centralizedRawSdkEventPayloads = + rawSdkEventPayloads ?? message?.rawSdkEventPayloads; + return buildTimeline( + thoughtItemsFromRawEventPayloads(centralizedRawSdkEventPayloads), + progressItemsFromCentralizedData( + centralizedRawSdkEventPayloads, + message?.rawResponse, + ), + html, + message?.parts, + ); +} + +const MAX_VISIBLE_COMPLETED_ACTIVITY = 5; type MessageViewState = { showActivityDetails: boolean; @@ -3225,7 +4026,12 @@ function parseTimelineStepTitle(rawTitle: string): { return { label: "event", summary: stripTrailingEllipsis(title) }; } -function buildDisplayEvents( +// LEGACY NORMALIZATION LAYER. +// +// This second pass reshapes the already-built timeline into UI-friendly rows. +// It is intentionally retained only as a migration bridge and should be +// removed when the centralized raw event tape becomes the sole render source. +function buildDisplayEvents( timelineBlocks: TimelineBlock[], message: Message | undefined, isStreamingActive: boolean, @@ -3520,62 +4326,16 @@ function buildDisplayEvents( } } - const collapsed: DisplayEvent[] = []; - for (const event of rawEvents) { - const previous = collapsed[collapsed.length - 1]; - - // Content-based deduplication: events with same content are duplicates - // regardless of status or source (stream vs final) - const isContentDuplicate = - !!previous && - previous.kind === event.kind && - previous.label === event.label && - previous.summary === event.summary && - (previous.filePath ?? "") === (event.filePath ?? "") && - (previous.internal ?? false) === (event.internal ?? false); - - if (!isContentDuplicate || !previous) { - collapsed.push({ ...event }); - continue; - } - - // When collapsing duplicate events, prefer higher-quality metadata: - // - "final" source over "stream" - // - "done" status over "pending" - // - "error" status over all others - previous.updateCount += 1; - if (event.description) previous.description = event.description; - if (event.detail) previous.detail = event.detail; - if (event.diffStats) previous.diffStats = event.diffStats; - if (event.activityDetail) previous.activityDetail = event.activityDetail; - if (event.viewDiffFile) previous.viewDiffFile = event.viewDiffFile; - if (event.partType) previous.partType = event.partType; - - // Prefer "final" source over "stream" - if (event.source === "final" && previous.source !== "final") { - previous.source = event.source; - } else if (!previous.source && event.source) { - previous.source = event.source; - } - - // Prefer terminal statuses over pending - if (event.status === "error") { - previous.status = event.status; - } else if (event.status === "done" && previous.status !== "error") { - previous.status = event.status; - } - - previous.internal = Boolean(previous.internal || event.internal); - } - - if (isStreamingActive) { - let latestPendingIndex = -1; - for (let index = collapsed.length - 1; index >= 0; index -= 1) { - if (collapsed[index].status === "pending") { - latestPendingIndex = index; - break; - } - } + const collapsed: DisplayEvent[] = rawEvents.map((event) => ({ ...event })); + + if (isStreamingActive) { + let latestPendingIndex = -1; + for (let index = collapsed.length - 1; index >= 0; index -= 1) { + if (collapsed[index].status === "pending") { + latestPendingIndex = index; + break; + } + } if (latestPendingIndex > 0) { for (let index = 0; index < latestPendingIndex; index += 1) { @@ -3793,26 +4553,44 @@ function getAgentName( * Type-safe helper to get token usage info from message. * Returns undefined for streaming state since tokens aren't available until completion. */ -function getTokenInfo(message: Message | undefined): - | { - input?: number; - output?: number; - reasoning?: number; - cache?: { read?: number; write?: number }; - } - | undefined { - if (!message) { - return undefined; - } - - if (message.info?.tokens) { - return message.info.tokens; - } - - if ("tokens" in message) { - const tokens = (message as Record).tokens; - if (tokens && typeof tokens === "object") { - return tokens as { +function getTokenInfo(message: Message | undefined): + | { + input?: number; + output?: number; + reasoning?: number; + cache?: { read?: number; write?: number }; + } + | undefined { + if (!message) { + return undefined; + } + + if (message.info?.tokens) { + return message.info.tokens; + } + + const rawInfoRec = rawResponseInfoRecordForUi(message.rawResponse); + const rawTokens = asRecord(rawInfoRec?.tokens); + if (rawTokens) { + const rawCache = asRecord(rawTokens.cache); + return { + input: typeof rawTokens.input === "number" ? rawTokens.input : undefined, + output: typeof rawTokens.output === "number" ? rawTokens.output : undefined, + reasoning: + typeof rawTokens.reasoning === "number" ? rawTokens.reasoning : undefined, + cache: rawCache + ? { + read: typeof rawCache.read === "number" ? rawCache.read : undefined, + write: typeof rawCache.write === "number" ? rawCache.write : undefined, + } + : undefined, + }; + } + + if ("tokens" in message) { + const tokens = (message as Record).tokens; + if (tokens && typeof tokens === "object") { + return tokens as { input?: number; output?: number; reasoning?: number; @@ -3842,17 +4620,27 @@ function getDuration( return undefined; } - if ( - message.info?.duration !== undefined && - typeof message.info.duration === "number" - ) { - return message.info.duration; - } - - if ("duration" in message) { - const duration = (message as Record).duration; - if (typeof duration === "number") { - return duration; + if ( + message.info?.duration !== undefined && + typeof message.info.duration === "number" + ) { + return message.info.duration; + } + + const rawInfoRec = rawResponseInfoRecordForUi(message.rawResponse); + const rawTimeRec = asRecord(rawInfoRec?.time); + if ( + typeof rawTimeRec?.created === "number" && + typeof rawTimeRec?.completed === "number" && + rawTimeRec.completed >= rawTimeRec.created + ) { + return (rawTimeRec.completed - rawTimeRec.created) / 1000; + } + + if ("duration" in message) { + const duration = (message as Record).duration; + if (typeof duration === "number") { + return duration; } } @@ -3940,7 +4728,7 @@ function formatThinkingVariantLabel(variant: string): string { return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase(); } -function AssistantMessageInner({ +function AssistantResponseCardInner({ message, streaming, isContiguous, @@ -3962,72 +4750,67 @@ function AssistantMessageInner({ subagentDetailsById?: AppState["subagentDetailsById"]; availableAgents?: AppState["availableAgents"]; todoItems?: AppState["todoItems"]; -}) { - const dispatch = useAppDispatch(); - const { assistantTurnPending, availableModels } = useAppState(); +}) { + const dispatch = useAppDispatch(); + const { + assistantTurnPending, + availableModels, + streamingBySessionId, + rawSdkEventPayloadsBySessionId, + } = useAppState(); const [showSubagents, setShowSubagents] = useState(true); - const [showAllSubagents, setShowAllSubagents] = useState(false); - const [showTodoChecklist, setShowTodoChecklist] = useState(true); - const [selectedSubagentId, setSelectedSubagentId] = useState( - null, - ); - const [copied, setCopied] = useState(false); - const [previewImageSrc, setPreviewImageSrc] = useState(null); - const messageBodyRef = useRef(null); - const progressTimelineRef = useRef(null); - const requestedSubagentConversationRef = useRef>(new Set()); - - const centralizedDebugData = useMemo(() => { - if (!config.debug.showCentralizedDebug) return null; - const safeReplacer = () => { - const seen = new WeakSet(); - return (key: string, value: unknown) => { - if (value === undefined) return '(undefined)'; - if (typeof value === 'function') return '(function)'; - if (value instanceof Error) return { message: value.message, name: value.name }; - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) return '(circular)'; - seen.add(value); - } - return value; - }; - }; - const sdkPayloads = streaming?.rawSdkEventPayloads ?? []; - const raw = message?.rawResponse; - let rawResponseValue: unknown = raw; - if (typeof raw === "string") { - try { rawResponseValue = JSON.parse(raw); } - catch { rawResponseValue = raw; } - } - return { - streamEventPayloads: sdkPayloads.length > 0 ? sdkPayloads : undefined, - rawResponse: rawResponseValue, - message: JSON.parse(JSON.stringify(message, safeReplacer())), - streaming: JSON.parse(JSON.stringify(streaming, safeReplacer())), - }; - }, [message, streaming, config.debug.showCentralizedDebug]); - - const sdkDebugData = useMemo(() => { - if (!config.debug.showSdkDebug) return null; - const sdkPayloads = streaming?.rawSdkEventPayloads ?? []; - const raw = message?.rawResponse; - let rawResponseValue: unknown = raw; - if (typeof raw === "string") { - try { rawResponseValue = JSON.parse(raw); } - catch { rawResponseValue = raw; } - } - return { - streamEventPayloads: sdkPayloads.length > 0 ? sdkPayloads : undefined, - rawResponse: rawResponseValue, - payloadCount: sdkPayloads.length, - }; - }, [streaming, message, config.debug.showSdkDebug]); - - const rawContent = getMessageContent(message, streaming); - const stickyStreamingContentRef = useRef<{ - messageId: string | null; - content: string; - }>({ messageId: null, content: "" }); + const [showAllSubagents, setShowAllSubagents] = useState(false); + const [showTodoChecklist, setShowTodoChecklist] = useState(true); + const [selectedSubagentId, setSelectedSubagentId] = useState( + null, + ); + const [copied, setCopied] = useState(false); + const [copiedDebugPanel, setCopiedDebugPanel] = useState<"sdk" | "centralized" | null>(null); + const [previewImageSrc, setPreviewImageSrc] = useState(null); + const messageBodyRef = useRef(null); + const progressTimelineRef = useRef(null); + const requestedSubagentConversationRef = useRef>(new Set()); + const activityTimelineMessage = message; + const activityTimelineStreaming = + streaming ?? (currentSessionId ? streamingBySessionId?.[currentSessionId] : undefined); + const sdkDebugData = useMemo(() => { + if (!config.debug.showSdkDebug) return null; + const sdkPayloads = + message?.rawSdkEventPayloads ?? + activityTimelineStreaming?.rawSdkEventPayloads ?? + []; + return { + streamEventPayloads: sdkPayloads.length > 0 ? sdkPayloads : undefined, + rawResponse: message?.rawResponse, + payloadCount: sdkPayloads.length, + }; + }, [message, activityTimelineStreaming, config.debug.showSdkDebug]); + const centralizedRawResponse = message?.rawResponse; + const centralizedMessageRec = asRecord(activityTimelineMessage); + const centralizedMessageInfoRec = asRecord(centralizedMessageRec?.info); + const centralizedSessionId = + currentSessionId || + asString(centralizedMessageInfoRec?.sessionID) || + asString(centralizedMessageInfoRec?.sessionId) || + asString(centralizedMessageRec?.sessionID) || + asString(centralizedMessageRec?.sessionId) || + null; + const centralizedRawSdkEventPayloads = useMemo(() => { + return centralizedSessionId && + Array.isArray(rawSdkEventPayloadsBySessionId?.[centralizedSessionId]) + ? rawSdkEventPayloadsBySessionId[centralizedSessionId] + : []; + }, [centralizedSessionId, rawSdkEventPayloadsBySessionId]); + const activityTimelineRawEventParts = useMemo(() => { + return rawMessagePartsFromRawSdkEventPayloads(centralizedRawSdkEventPayloads); + }, [centralizedRawSdkEventPayloads]); + const cardMessage = activityTimelineMessage; + const cardStreaming = activityTimelineStreaming; + const rawContent = getMessageContent(centralizedRawSdkEventPayloads); + const stickyStreamingContentRef = useRef<{ + messageId: string | null; + content: string; + }>({ messageId: null, content: "" }); const activeStreamingMessageId = streaming?.messageId || message?.info?.id || message?.id || null; if (stickyStreamingContentRef.current.messageId !== activeStreamingMessageId) { @@ -4043,54 +4826,55 @@ function AssistantMessageInner({ streaming?.isActive && rawContent.trim().length === 0 ? stickyStreamingContentRef.current.content : rawContent; - const hasAssistantFinishSignal = - streaming?.hasAssistantFinishSignal === true; - const hasActiveReasoningPart = streaming?.inReasoningPart === true; - const hasTerminalStepSignal = - streaming?.hasTerminalStepSignal === true; + const hasAssistantFinishSignal = + cardStreaming?.hasAssistantFinishSignal === true; + const hasActiveReasoningPart = cardStreaming?.inReasoningPart === true; + const hasTerminalStepSignal = + cardStreaming?.hasTerminalStepSignal === true; const liveInteractivePrompt = useMemo( - () => questionPromptFromInteractiveEvents(interactiveEvents), - [interactiveEvents], - ); - const shouldUseInteractivePromptFallback = - !!streaming?.isActive && - content.trim().length === 0 && - !!liveInteractivePrompt; - const resolvedContent = shouldUseInteractivePromptFallback - ? (liveInteractivePrompt ?? "") - : content; - const thoughtItems = useMemo( - () => - streaming - ? (streaming.isActive && !hasAssistantFinishSignal - ? [] - : thoughtItemsFromStreaming(streaming)) - : thoughtItemsFromMessage(message), - [streaming, message, hasAssistantFinishSignal], - ); - const progressItems = useMemo( - () => - streaming - ? progressItemsFromStreaming(streaming) - : progressItemsFromMessage(message), - [streaming, message], - ); + () => + questionPromptFromInteractiveEvents( + getInteractiveEventsFromRawSdkEventPayloads( + centralizedRawSdkEventPayloads, + ), + ), + [centralizedRawSdkEventPayloads], + ); + const shouldUseInteractivePromptFallback = + !!cardStreaming?.isActive && + content.trim().length === 0 && + !!liveInteractivePrompt; + const resolvedContent = shouldUseInteractivePromptFallback + ? (liveInteractivePrompt ?? "") + : content; + const thoughtItems = useMemo( + () => { + return thoughtItemsFromRawEventPayloads(centralizedRawSdkEventPayloads); + }, + [centralizedRawSdkEventPayloads], + ); + const progressItems = useMemo( + () => { + return progressItemsFromCentralizedData(centralizedRawSdkEventPayloads); + }, + [centralizedRawSdkEventPayloads], + ); /** Unified chronological list of timeline blocks to render. */ const timelineBlocks = useMemo(() => { return buildTimeline( - thoughtItems, - progressItems, - resolvedContent, - message?.parts, - ); - }, [thoughtItems, progressItems, resolvedContent, message?.parts]); - const isStreamingActive = !!streaming?.isActive; - const displayEvents = useMemo( - () => { - const events = buildDisplayEvents(timelineBlocks, message, isStreamingActive, assistantTurnPending); - // Debug: Log all display events with read/edit labels - const readEditEvents = events.filter(e => e.label.toLowerCase() === 'read' || e.label.toLowerCase() === 'edit'); + thoughtItems, + progressItems, + resolvedContent, + activityTimelineRawEventParts as MessagePart[], + ); + }, [thoughtItems, progressItems, resolvedContent, activityTimelineRawEventParts]); + const isStreamingActive = !!activityTimelineStreaming?.isActive; + const displayEvents = useMemo( + () => { + const events = buildDisplayEvents(timelineBlocks, activityTimelineMessage, isStreamingActive, assistantTurnPending); + // Debug: Log all display events with read/edit labels + const readEditEvents = events.filter(e => e.label.toLowerCase() === 'read' || e.label.toLowerCase() === 'edit'); if (readEditEvents.length > 0) { console.log('[DEBUG] DisplayEvents with read/edit labels:', { count: readEditEvents.length, @@ -4106,43 +4890,67 @@ function AssistantMessageInner({ }); } return events; - }, - [timelineBlocks, message, isStreamingActive, assistantTurnPending], - ); - const hasPendingReasoningDisplayEvent = useMemo( - () => - displayEvents.some( - (event) => event.kind === "reasoning" && event.status === "pending", + }, + [timelineBlocks, activityTimelineMessage, isStreamingActive, assistantTurnPending], + ); + // Centralized debug is the long-term source of truth for this assistant turn. + // Keep it raw and complete so future UI rendering can consume the same data + // without depending on derived display-only transforms. + const centralizedDebugData = useMemo(() => { + if (!config.debug.showCentralizedDebug) { + return {}; + } + + const rawEventStream: CentralizedDebugSourceData = { + sessionId: centralizedSessionId ?? currentSessionId ?? undefined, + rawSdkEventPayloads: centralizedRawSdkEventPayloads, + }; + + // Keep the debug panel mounted even before the assistant responds so the + // raw session tape can grow in-place as soon as the first event lands. + return { + rawEventStream, + }; + }, [ + centralizedRawSdkEventPayloads, + centralizedSessionId, + currentSessionId, + config.debug.showCentralizedDebug, + ]); + const hasPendingReasoningDisplayEvent = useMemo( + () => + displayEvents.some( + (event) => event.kind === "reasoning" && event.status === "pending", ), [displayEvents], ); - const info = message?.info; - const messageRec = asRecord(message); - const infoRec = asRecord(messageRec?.info); - const structured = message?.structuredOutput; - const responseType = firstNonEmptyString( - message?.responseType, - typeof structured?.responseType === "string" ? structured.responseType : undefined, - )?.toLowerCase(); - const plan = message?.plan; - const changeSummary = message?.changeSummary; + const info = activityTimelineMessage?.info; + const messageRec = asRecord(activityTimelineMessage); + const infoRec = asRecord(messageRec?.info); + const structured = activityTimelineMessage?.structuredOutput; + const responseType = firstNonEmptyString( + activityTimelineMessage?.responseType, + typeof structured?.responseType === "string" ? structured.responseType : undefined, + )?.toLowerCase(); + const plan = activityTimelineMessage?.plan; + const changeSummary = activityTimelineMessage?.changeSummary; // Match the same ID extraction logic as backend extractMessageId() // https://github.com/anthropics/opencode-vscode/blob/main/src/providers/ChatViewProvider.ts#L1988-L2000 const messageId = info?.id || - message?.id || - message?.messageId || - info?.messageId || - streaming?.messageId; - const hasOwnedChangeSummary = messageOwnsChangeSummary( - message, - messageId, - changeSummary, - ); - const shouldShowFileChanges = useMemo(() => { - if (!message || !messageHasOwnFileChangeEvidence(message)) { - return false; - } + activityTimelineMessage?.id || + activityTimelineMessage?.messageId || + info?.messageId || + activityTimelineStreaming?.messageId; + const hasOwnedChangeSummary = messageOwnsChangeSummary( + activityTimelineMessage, + messageId, + changeSummary, + ); + const shouldShowFileChanges = useMemo(() => { + if (!activityTimelineMessage || !messageHasOwnFileChangeEvidence(activityTimelineMessage)) { + return false; + } // Implementation plan turns already surface their own plan card, so the // aggregated diff section would just duplicate the same turn. @@ -4150,34 +4958,33 @@ function AssistantMessageInner({ return false; } - const ownFiles = fileChangePathsFromMessage(message); + const ownFiles = fileChangePathsFromMessage(activityTimelineMessage); // If summary ownership metadata is missing, keep local evidence visible. // This avoids dropping the file-change card when providers omit // message-scoped diff summary payloads. - if (!hasOwnedChangeSummary) { - return ( - ownFiles.size > 0 || - (Array.isArray(message.steps) && message.steps.length > 0) || - (Array.isArray(message.progressEvents) && message.progressEvents.length > 0) - ); - } + if (!hasOwnedChangeSummary) { + return ( + ownFiles.size > 0 || + progressItems.length > 0 + ); + } if (ownFiles.size === 0) { return true; } - const ownIndex = messages.findIndex( - (candidate) => - candidate === message || - (!!messageId && (candidate.info?.id === messageId || candidate.id === messageId)), - ); - const ownRichness = fileChangeRenderRichness(message); - - return !messages.some((candidate, index) => { - if (candidate === message) { - return false; - } + const ownIndex = messages.findIndex( + (candidate) => + candidate === message || + (!!messageId && (candidate.info?.id === messageId || candidate.id === messageId)), + ); + const ownRichness = fileChangeRenderRichness(activityTimelineMessage); + + return !messages.some((candidate, index) => { + if (candidate === message) { + return false; + } const candidateFiles = fileChangePathsFromMessage(candidate); if (candidateFiles.size === 0) { return false; @@ -4191,7 +4998,7 @@ function AssistantMessageInner({ } return candidateRichness === ownRichness && ownIndex >= 0 && index > ownIndex; }); - }, [hasOwnedChangeSummary, message, messageId, messages]); + }, [hasOwnedChangeSummary, activityTimelineMessage, messageId, messages, progressItems]); const shouldShowPlanCard = useMemo(() => { if (responseType !== "implementation_plan" || !plan) { return false; @@ -4262,9 +5069,9 @@ function AssistantMessageInner({ const visibleMainEvents = hasCompletedCondensedActivity ? mainDisplayEvents.slice(-MAX_VISIBLE_COMPLETED_ACTIVITY) : mainDisplayEvents; - const visibleDisplayEvents = pendingStreamReasoning - ? [...visibleMainEvents, pendingStreamReasoning] - : visibleMainEvents; + const visibleDisplayEvents = pendingStreamReasoning + ? [...visibleMainEvents, pendingStreamReasoning] + : visibleMainEvents; const userFacingDisplayEvents = visibleDisplayEvents.filter((event) => { if (event.internal) return false; return true; @@ -4276,10 +5083,12 @@ function AssistantMessageInner({ viewState.showInternalActivity && internalDisplayEvents.length > 0 ? visibleDisplayEvents : userFacingDisplayEvents; - // Pending reasoning derived from streaming.content (not explicit reasoning - // events) represents live in-progress work — push it to the end so it always - // appears as the latest activity step in the timeline. - if (pendingStreamReasoning) { + // TEMPORARY MIGRATION BEHAVIOR: + // Pending reasoning derived from streaming.content is still pinned to the end + // so the current UI stays usable while we transition away from this + // normalization layer. Remove this once the raw centralized sequence is the + // sole source of render order. + if (pendingStreamReasoning) { const pinnedIdx = timelineDisplayEvents.findIndex( (e) => e.key === pendingStreamReasoning.key, ); @@ -4469,6 +5278,19 @@ function AssistantMessageInner({ // Prefer store-scoped entries so subagent cards cannot bleed into unrelated messages. const subagents = useMemo(() => { const activeSessionId = currentSessionId; + const dedupeSubagentsById = (entries: SubagentSummary[]): SubagentSummary[] => { + const deduped = new Map(); + for (const entry of entries) { + if (!entry?.id) { + continue; + } + const existing = deduped.get(entry.id); + // Later updates for the same subagent should win so the inline card and + // modal show the freshest progress / status instead of duplicate rows. + deduped.set(entry.id, existing ? { ...existing, ...entry } : entry); + } + return Array.from(deduped.values()); + }; const isInActiveSession = (subagent: SubagentSummary): boolean => { if (!activeSessionId) { return true; @@ -4488,7 +5310,7 @@ function AssistantMessageInner({ }); } - const fromStore = scopedStore.filter((subagent: SubagentSummary) => { + const fromStore = dedupeSubagentsById(scopedStore.filter((subagent: SubagentSummary) => { if (!isInActiveSession(subagent)) { return false; } @@ -4496,9 +5318,9 @@ function AssistantMessageInner({ return true; } return subagent.parentMessageId === messageId; - }); + })); const messageSubagents = Array.isArray(message?.subagents) ? message.subagents : []; - const fromMessage = messageSubagents.filter((subagent: SubagentSummary) => { + const fromMessage = dedupeSubagentsById(messageSubagents.filter((subagent: SubagentSummary) => { if (!isInActiveSession(subagent)) { return false; } @@ -4506,7 +5328,7 @@ function AssistantMessageInner({ return true; } return subagent.parentMessageId === messageId; - }); + })); if (getGlobalShowBrowserConsole()) { console.log('===SUBAGENT_SPAWN=== [MEMO] Filter results', { @@ -4520,11 +5342,18 @@ function AssistantMessageInner({ if (fromStore.length === 0) return fromMessage; if (fromMessage.length === 0) return fromStore; - // Merge: store entries take precedence (more up-to-date), then append - // message-scoped entries not yet in the store snapshot. - const storeIds = new Set(fromStore.map((s: SubagentSummary) => s.id)); - const extra = fromMessage.filter((s) => !storeIds.has(s.id)); - const result = [...fromStore, ...extra]; + // Merge by ID so the same subagent cannot appear twice when the message + // payload and the store snapshot both report it during hydration/streaming. + const mergedById = new Map(); + for (const subagent of fromStore) { + mergedById.set(subagent.id, subagent); + } + for (const subagent of fromMessage) { + if (!mergedById.has(subagent.id)) { + mergedById.set(subagent.id, subagent); + } + } + const result = Array.from(mergedById.values()); if (getGlobalShowBrowserConsole()) { console.log('===SUBAGENT_SPAWN=== [MEMO] Final result', { @@ -4642,6 +5471,9 @@ function AssistantMessageInner({ (Array.isArray(streaming.progressEvents) && streaming.progressEvents.length > 0) || (Array.isArray(streaming.steps) && streaming.steps.length > 0) || + (Array.isArray(interactiveEvents) && interactiveEvents.length > 0) || + (Array.isArray(streaming.interactiveEvents) && + streaming.interactiveEvents.length > 0) || subagents.length > 0) ); @@ -4749,37 +5581,25 @@ function AssistantMessageInner({ const hasActiveTimelineWork = timelineDisplayEvents.some( (event) => event.status === "pending", ); - const { rawResponseText } = useMemo(() => { - const maxRawDebugChars = 30000; - const withCap = (value: string): string => - value.length > maxRawDebugChars - ? `${value.slice(0, maxRawDebugChars)}\n...` - : value; - const raw = message?.rawResponse; - if (typeof raw === "undefined") { - return { - rawResponseText: "(rawResponse is missing on this message)", - }; - } - let displayValue: unknown = raw; - if (typeof raw === "string") { - try { - displayValue = JSON.parse(raw); - } catch { - displayValue = raw; - } - } - try { - return { - rawResponseText: withCap(JSON.stringify(displayValue, null, 2)), - }; - } catch { - return { - rawResponseText: withCap(String(raw)), - }; - } - }, [message?.rawResponse]); + const { rawResponseText } = useMemo(() => { + const maxRawDebugChars = 30000; + const withCap = (value: string): string => + value.length > maxRawDebugChars + ? `${value.slice(0, maxRawDebugChars)}\n...` + : value; + const raw = centralizedRawResponse; + if (typeof raw === "undefined") { + return { + rawResponseText: "(rawResponse is missing on this message)", + }; + } + const rawResponseText = + typeof raw === "string" ? raw : stringifyDebugValue(raw); + return { + rawResponseText: withCap(rawResponseText), + }; + }, [centralizedRawResponse]); const showRawResponseDebug = config.debug.showRawResponse; const visibleRawResponseText = rawResponseText.trim().length > 0 @@ -4788,26 +5608,26 @@ function AssistantMessageInner({ const planLeadMessage = useMemo(() => { if (!plan) return ""; const candidate = ( - firstNonEmptyString( - message?.message, - typeof structured?.message === "string" ? structured.message : undefined, - plan.intro, - plan.summary, - ) ?? "" - ).trim(); + firstNonEmptyString( + cardMessage?.message, + typeof structured?.message === "string" ? structured.message : undefined, + plan.intro, + plan.summary, + ) ?? "" + ).trim(); if (candidate && !looksLikeInternalPlanningText(candidate)) { return candidate; } return "I created an implementation plan. Here are the key steps and the plan file."; - }, [message?.message, structured?.message, plan]); - const hasThinkingEvents = useMemo( - () => displayEvents.some((event) => event.kind === "reasoning"), - [displayEvents], - ); - const resolvedContentMatchesError = messageDisplaysSameErrorText( - message, - resolvedContent, - ); + }, [cardMessage?.message, structured?.message, plan]); + const hasThinkingEvents = useMemo( + () => displayEvents.some((event) => event.kind === "reasoning"), + [displayEvents], + ); + const resolvedContentMatchesError = messageDisplaysSameErrorText( + cardMessage, + resolvedContent, + ); const visibleResolvedContent = resolvedContentMatchesError ? "" : resolvedContent; @@ -4823,30 +5643,30 @@ function AssistantMessageInner({ const hasVisibleResponseBody = effectiveResponseContent.trim().length > 0; const hasPrimaryResponseBody = hasVisibleResponseBody || shouldShowPlanCard; const hasResponseContent = hasVisibleResponseBody; - const isAborted = message?.aborted === true; - const structuredRetryError = - !!message?.error && - (message.retryWithoutStructuredOutput === true || - isStructuredOutputFailureMessage(message.error)); - const showLegacyErrorBanner = - !!message?.error && - !messageMatchesDisplayErrorText(message, message.error) && - !structuredRetryError && - !isAborted; - const showDisplayErrorBanner = !!message?.displayError; - const plainTextFallback = message?.plainTextFallback === true; - const plainTextFallbackTooltip = plainTextFallback - ? [ - message.plainTextFallbackMessage || - "Structured output failed for this turn. Showing plain text response.", - message.plainTextFallbackReason - ? `Reason: ${message.plainTextFallbackReason}` - : "", - ] - .filter(Boolean) - .join("\n") - : ""; - const isLiveStreamingCard = !message && !!streaming?.isActive; + const isAborted = cardMessage?.aborted === true; + const structuredRetryError = + !!cardMessage?.error && + (cardMessage.retryWithoutStructuredOutput === true || + isStructuredOutputFailureMessage(cardMessage.error)); + const showLegacyErrorBanner = + !!cardMessage?.error && + !messageMatchesDisplayErrorText(cardMessage, cardMessage.error) && + !structuredRetryError && + !isAborted; + const showDisplayErrorBanner = !!cardMessage?.displayError; + const plainTextFallback = cardMessage?.plainTextFallback === true; + const plainTextFallbackTooltip = plainTextFallback + ? [ + cardMessage?.plainTextFallbackMessage || + "Structured output failed for this turn. Showing plain text response.", + cardMessage?.plainTextFallbackReason + ? `Reason: ${cardMessage.plainTextFallbackReason}` + : "", + ] + .filter(Boolean) + .join("\n") + : ""; + const isLiveStreamingCard = !cardMessage && !!streaming?.isActive; const responseBodyClass = isLiveStreamingCard ? "w-full" : "w-full"; @@ -4854,49 +5674,100 @@ function AssistantMessageInner({ ? "w-full max-w-none" : "w-full"; const isLiveStream = !!streaming?.isActive; - const isEvtLifecycleMessage = typeof messageId === "string" && messageId.startsWith("evt_"); - const hasCanonicalMsgAlternative = isEvtLifecycleMessage && Array.isArray(messages) && messages.some( - (m) => { - const mId = m?.info?.id || m?.id; - return typeof mId === "string" && mId.startsWith("msg_") && m?.role === "assistant"; - }, - ); // Hide the response markdown body during live streaming to prevent // reasoning/planning text from leaking into the AI response card. // The plan card and other non-text response elements remain visible. - const showResponseBody = hasResponseContent && !isLiveStream && !hasCanonicalMsgAlternative; + const showResponseBody = hasResponseContent && !isLiveStream; const showResponseSection = !isAborted && - (hasVisibleResponseBody || shouldShowPlanCard) && - (!isLiveStream || hasAssistantFinishSignal) && - (!isLiveStream || - (!hasActiveTimelineWork && - !hasActiveReasoningPart && - !hasPendingReasoningDisplayEvent)); - - const cardDebugData = useMemo(() => { - if (!config.debug.showCentralizedDebug) return null; - return { - effectiveResponseContent: (effectiveResponseContent || "").slice(0, 500), - effectiveResponseContentLen: (effectiveResponseContent || "").length, - streamingActive: !!streaming?.isActive, - streamingContent: (streaming?.content || "").slice(0, 500), - streamingReasoning: (streaming?.reasoning || "").slice(0, 200), - hasRenderableContent: !!streaming?.hasRenderableContent, - hasAssistantFinishSignal: streaming?.hasAssistantFinishSignal, - hasTerminalStepSignal: streaming?.hasTerminalStepSignal, + (hasVisibleResponseBody || + shouldShowPlanCard || + (isLiveStream && + (hasStreamingActivity || + displayEvents.length > 0 || + hasActiveTimelineWork || + hasActiveReasoningPart || + hasPendingReasoningDisplayEvent))); + + useEffect(() => { + if ( + !streaming?.isActive && + displayEvents.length === 0 && + !config.debug.showCentralizedDebug && + !config.debug.showPreRenderDebug + ) { + return; + } + + logger.info("[TRACE][RENDER][ASSISTANT_MESSAGE]", { + messageId: messageId || null, + streamingMessageId: streaming?.messageId ?? null, + streamingActive: !!streaming?.isActive, + hasStreamingActivity, + showStreamingLoading, + displayEventsCount: displayEvents.length, + timelineDisplayEventsCount: timelineDisplayEvents.length, + hasActiveTimelineWork, + hasActiveReasoningPart, + hasPendingReasoningDisplayEvent, + showResponseSection, showResponseBody, + hasVisibleResponseBody, + hasPrimaryResponseBody, + hasAssistantFinishSignal, + messageInteractiveEvents: Array.isArray(cardMessage?.interactiveEvents) + ? cardMessage.interactiveEvents.length + : 0, + streamingInteractiveEvents: Array.isArray(streaming?.interactiveEvents) + ? streaming.interactiveEvents.length + : 0, + }); + console.info("[TRACE][RENDER][ASSISTANT_MESSAGE]", { + messageId: messageId || null, + streamingMessageId: streaming?.messageId ?? null, + streamingActive: !!streaming?.isActive, + hasStreamingActivity, + showStreamingLoading, + displayEventsCount: displayEvents.length, + timelineDisplayEventsCount: timelineDisplayEvents.length, + hasActiveTimelineWork, + hasActiveReasoningPart, + hasPendingReasoningDisplayEvent, showResponseSection, + showResponseBody, hasVisibleResponseBody, hasPrimaryResponseBody, - isLiveStream, - isAborted, - }; - }, [effectiveResponseContent, streaming, showResponseBody, showResponseSection, hasVisibleResponseBody, hasPrimaryResponseBody, isLiveStream, isAborted, config.debug.showCentralizedDebug]); + hasAssistantFinishSignal, + messageInteractiveEvents: Array.isArray(cardMessage?.interactiveEvents) + ? cardMessage.interactiveEvents.length + : 0, + streamingInteractiveEvents: Array.isArray(streaming?.interactiveEvents) + ? streaming.interactiveEvents.length + : 0, + }); + }, [ + messageId, + cardMessage?.interactiveEvents, + streaming, + displayEvents.length, + timelineDisplayEvents.length, + hasActiveTimelineWork, + hasActiveReasoningPart, + hasPendingReasoningDisplayEvent, + showResponseSection, + showResponseBody, + hasVisibleResponseBody, + hasPrimaryResponseBody, + hasAssistantFinishSignal, + hasStreamingActivity, + showStreamingLoading, + config.debug.showCentralizedDebug, + config.debug.showPreRenderDebug, + ]); - const preRenderDebug = useMemo(() => { - if (!config.debug.showPreRenderDebug) return null; - const streamingContent = streaming?.content || ''; + const preRenderDebug = useMemo(() => { + if (!config.debug.showPreRenderDebug) return null; + const streamingContent = streaming?.content || ''; const streamingReasoning = streaming?.reasoning || ''; const reasoningEvents = streaming?.reasoningEvents || []; const reasoningEventSummaries = reasoningEvents.map((e: ReasoningEvent) => @@ -4972,9 +5843,9 @@ function AssistantMessageInner({ const responseSectionClass = hasResponseContent ? "rounded-md border border-oc-border-soft bg-background p-3.5 shadow-sm" : "p-0 border-0 bg-transparent shadow-none"; - const handleCopy = async () => { - const textToCopy = resolvedContent?.trim() ?? ""; - if (!textToCopy) return; + const handleCopy = async () => { + const textToCopy = resolvedContent?.trim() ?? ""; + if (!textToCopy) return; try { await navigator.clipboard.writeText(textToCopy); @@ -4986,10 +5857,27 @@ function AssistantMessageInner({ // fallback to extension-host clipboard API. vscode.postMessage({ type: "copyToClipboard", text: textToCopy }); setCopied(true); - setTimeout(() => setCopied(false), 1200); - } - }; - const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { + setTimeout(() => setCopied(false), 1200); + } + }; + const copyDebugObject = async (panel: "sdk" | "centralized", value: unknown) => { + const textToCopy = formatDebugObjectLiteral(value); + if (!textToCopy) return; + + try { + await navigator.clipboard.writeText(textToCopy); + setCopiedDebugPanel(panel); + setTimeout(() => setCopiedDebugPanel((current) => (current === panel ? null : current)), 1200); + return; + } catch { + // VS Code webviews can block navigator clipboard in some contexts; + // fallback to extension-host clipboard API. + vscode.postMessage({ type: "copyToClipboard", text: textToCopy }); + setCopiedDebugPanel(panel); + setTimeout(() => setCopiedDebugPanel((current) => (current === panel ? null : current)), 1200); + } + }; + const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { dispatch({ type: "SET_PROCESSING", payload: true }); const targetMessageIndex = messages.findIndex((candidate) => { if (messageId) { @@ -5069,11 +5957,12 @@ function AssistantMessageInner({ return () => root.removeEventListener("click", onClick); }, []); - const responseEnterClass = streaming - ? "oc-assistant-streaming-enter" - : "oc-assistant-response-enter"; + const responseEnterClass = streaming + ? "oc-assistant-streaming-enter" + : "oc-assistant-response-enter"; + const isCentralizedDebugLive = !!streaming?.isActive; - return ( + return (
- {config.debug.showSdkDebug && sdkDebugData && ( -
-
-
- SDK Debug -
-
-
-              {JSON.stringify(sdkDebugData, null, 2)}
-            
-
- )} - {config.debug.showCentralizedDebug && centralizedDebugData && ( -
-
-
- Centralized Debug -
-
-
-              {JSON.stringify(centralizedDebugData, null, 2)}
-            
-
- )} + {config.debug.showSdkDebug && sdkDebugData && ( +
+
+
+ SDK Debug +
+ +
+ {/* Keep this as an object literal view so the raw SDK payload is easy to inspect. */} +
+ +
+
+ )} + {config.debug.showCentralizedDebug && ( +
+
+
+ Centralized Debug +
+
+
+ + {isCentralizedDebugLive ? "Live" : "Static"} +
+ +
+
+ {/* Keep this as an object literal view so the centralized payload stays readable. */} +
+ +
+
+ )} {!isContiguous && (
@@ -5232,10 +6160,10 @@ function AssistantMessageInner({
{(displayEvents.length > 0 || showThinkingPlaceholder) && ( -
- {timelineDisplayEvents.length > 0 && ( - <> - + {timelineDisplayEvents.length > 0 && ( + <> +
- {event.filePath && !isUrl(event.filePath) && !event.label.startsWith('call_') ? ( + {event.filePath && !isUrl(event.filePath) && !isCallStyleActivityLabel(event.label) ? ( SEARCH_LABELS.has(event.label) ? ( )} - {showResponseSection && ( -
- {config.debug.showCentralizedDebug && cardDebugData && ( -
-
-
- Card Data (Debug) -
-
-
-                    {JSON.stringify(cardDebugData, null, 2)}
-                  
-
- )} - {showResponseBody && ( -
- + {showResponseBody && ( +
+ @@ -5955,7 +6868,7 @@ function AssistantMessageInner({ )} {(() => { - const ts = formatMessageTime(getMessageTimestamp(message)); + const ts = formatMessageTime(getMessageTimestamp(cardMessage)); return ts ? ( {ts} @@ -5965,7 +6878,7 @@ function AssistantMessageInner({
)} - {isAborted && !hasQuestionLikeInteractiveContent(message) && ( + {isAborted && !hasQuestionLikeInteractiveContent(cardMessage) && (
@@ -5977,12 +6890,12 @@ function AssistantMessageInner({ {showLegacyErrorBanner && (
{(() => { - const retryWithoutStructuredOutput = - message.retryWithoutStructuredOutput === true || - isStructuredOutputFailureMessage(message.error); - return ( - - -
- )} + {showDisplayErrorBanner && ( +
+ +
+ )} {message?.retryState === "retrying_without_structured_output" && (
@@ -6065,20 +6978,18 @@ function AssistantMessageInner({ {/* File Changes - aggregated diffs at the bottom */} {/* Only show for the specific message that has file changes, not for every message */} - {shouldShowFileChanges && ( -
- -
+ {shouldShowFileChanges && ( +
+ +
)} {isStreamingActive && @@ -6102,30 +7013,30 @@ function AssistantMessageInner({
                 {JSON.stringify(
                   {
-                    message: message
-                      ? {
-                          id: message.id,
-                          role: message.role,
-                          contentLength:
-                            message.content?.length ||
-                            message.text?.length ||
-                            0,
-                          partsCount: message.parts?.length || 0,
-                          info: message.info,
-                          hasReasoning:
-                            !!message.reasoningEvents?.length ||
-                            !!message.parts?.some(
-                              (p) => p.reasoning || p.thought || p.thinking,
-                            ),
-                          hasSteps: !!message.steps?.length,
-                          hasProgressEvents: !!message.progressEvents?.length,
-                          hasSubagents: !!message.subagents?.length,
-                          hasPlan: !!message.plan,
-                        edits: message.edits?.map((file: { file: string }) => file.file),
-                        createdAt: message.created,
-                        duration: message.info?.duration ?? message.duration,
-                        }
-                      : null,
+                    message: activityTimelineMessage
+                      ? {
+                          id: activityTimelineMessage.id,
+                          role: activityTimelineMessage.role,
+                          contentLength:
+                            activityTimelineMessage.content?.length ||
+                            activityTimelineMessage.text?.length ||
+                            0,
+                          partsCount: activityTimelineMessage?.parts?.length || 0,
+                          info: activityTimelineMessage.info,
+                          hasReasoning:
+                            !!activityTimelineMessage.reasoningEvents?.length ||
+                            !!activityTimelineMessage.parts?.some(
+                              (p) => p.reasoning || p.thought || p.thinking,
+                            ),
+                          hasSteps: !!activityTimelineMessage?.steps?.length,
+                          hasProgressEvents: !!activityTimelineMessage?.progressEvents?.length,
+                          hasSubagents: !!activityTimelineMessage.subagents?.length,
+                          hasPlan: !!activityTimelineMessage.plan,
+                        edits: activityTimelineMessage.edits?.map((file: { file: string }) => file.file),
+                        createdAt: activityTimelineMessage.created,
+                        duration: activityTimelineMessage.info?.duration ?? activityTimelineMessage.duration,
+                        }
+                      : null,
                     streaming: streaming
                       ? {
                           isActive: streaming.isActive,
@@ -6674,7 +7585,7 @@ function FileChangesSection({
   );
 }
 
-export function AssistantMessage({
+export function AssistantResponseCard({
   message,
   streaming,
   isContiguous,
@@ -6698,10 +7609,10 @@ export function AssistantMessage({
   todoItems?: AppState["todoItems"];
 }) {
   return (
-     0);
 
   const currentSession = currentSessionId
     ? sessionsList.find((s) => s.id === currentSessionId)
@@ -334,11 +327,6 @@ export function StickyHeader() {
           
         ) : null}
         {sessionTitle}
-        {isAiResponding && (
-          
-            Thinking...
-          
-        )}
       
{/* Right side: Action buttons */} @@ -1820,6 +1808,7 @@ export function InputWrapper() { processingSessionIds, executingQueueSessionIds, messages, + rawSdkEventPayloadsBySessionId, promptQueue, selectedFiles, selectedContexts, @@ -1851,42 +1840,29 @@ export function InputWrapper() { currentSessionId, executingQueueSessionIds, ); + const hasCompletedAssistantReply = + hasCompletedAssistantReplyForLatestTurn(messages); + const hasActiveTurnContext = hasActiveAssistantTurnContext( + messages, + Boolean(streaming?.isActive), + assistantTurnPending, + ); - const hasCompletedAssistantReplyForLatestTurn = (() => { - if (messages.length === 0) { - return false; - } - for (let i = messages.length - 1; i >= 0; i -= 1) { - const message = messages[i]; - if (message.role === "assistant") { - const text = - typeof message.content === "string" ? message.content.trim() : ""; - const structuredText = - typeof message.structuredOutput?.message === "string" - ? message.structuredOutput.message.trim() - : ""; - if (text.length > 0 || structuredText.length > 0) { - return true; - } - continue; - } - if (message.role === "user") { - return false; - } - } - return false; - })(); - - // Stop button only visible when AI is responding AND input is empty - // Send button icon reflects: Send icon when idle/input has value, AlertCircle when responding with input. - // Centralized check: the AI is responding when the session is processing - // and either streaming is active, the assistant turn is still pending, - // or active streaming reasoning events are still flowing. - const isAiResponding = - isProcessing && - (streaming?.isActive || - assistantTurnPending || - (streaming?.reasoningEvents?.length ?? 0) > 0); + // The composer stop/send toggle must follow the same in-flight session state + // that drives the transcript loading bubble. If the session is still + // processing, the request should remain abortable even before streaming or + // assistant-turn markers become visible in the composer state. + const isAiResponding = isAssistantRespondingInCurrentSession( + isProcessing, + currentSessionId, + processingSessionIds, + Boolean(streaming?.isActive), + assistantTurnPending, + streaming?.hasAssistantFinishSignal, + streaming?.hasTerminalStepSignal, + hasCompletedAssistantReply, + hasActiveTurnContext, + ); const textareaRef = useRef(null); const textareaHasValue = inputValue.length > 0; @@ -1931,58 +1907,18 @@ export function InputWrapper() { const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const commandsRequestedRef = useRef(false); const suggestionsContainerRef = useRef(null); + const centralizedRawSdkEventPayloads = useMemo(() => { + return currentSessionId && + Array.isArray(rawSdkEventPayloadsBySessionId?.[currentSessionId]) + ? rawSdkEventPayloadsBySessionId[currentSessionId] + : []; + }, [currentSessionId, rawSdkEventPayloadsBySessionId]); const composerInteractiveEvents = useMemo(() => { - if (Array.isArray(interactiveEvents) && interactiveEvents.length > 0) { - return filterDismissedInteractiveEvents( - interactiveEvents, - dismissedInteractiveEventKeys, - ); - } - - if ( - streaming && - Array.isArray(streaming.interactiveEvents) && - streaming.interactiveEvents.length > 0 - ) { - return filterDismissedInteractiveEvents( - streaming.interactiveEvents, - dismissedInteractiveEventKeys, - ); - } - - for (let index = messages.length - 1; index >= 0; index -= 1) { - const candidate = messages[index]; - const role = (candidate.role ?? candidate.info?.role ?? "").toLowerCase(); - if (role !== "assistant") { - continue; - } - - if ( - Array.isArray(candidate.interactiveEvents) && - candidate.interactiveEvents.length > 0 - ) { - return filterDismissedInteractiveEvents( - candidate.interactiveEvents, - dismissedInteractiveEventKeys, - ); - } - - const structured = candidate.structuredOutput ?? candidate.structured ?? (candidate.info as Record | undefined)?.structuredOutput ?? (candidate.info as Record | undefined)?.structured; - const structuredInteractiveEvents = structured && - typeof structured === "object" && - Array.isArray((structured as { interactiveEvents?: InteractiveEvent[] }).interactiveEvents) - ? ((structured as { interactiveEvents?: InteractiveEvent[] }).interactiveEvents as InteractiveEvent[]) - : []; - if (structuredInteractiveEvents.length > 0) { - return filterDismissedInteractiveEvents( - structuredInteractiveEvents, - dismissedInteractiveEventKeys, - ); - } - } - - return []; - }, [interactiveEvents, streaming, messages, dismissedInteractiveEventKeys]); + return filterDismissedInteractiveEvents( + getInteractiveEventsFromRawSdkEventPayloads(centralizedRawSdkEventPayloads), + dismissedInteractiveEventKeys, + ); + }, [centralizedRawSdkEventPayloads, dismissedInteractiveEventKeys]); const filteredCommands = useMemo(() => { if (!slashTrigger) { @@ -2130,17 +2066,33 @@ export function InputWrapper() { const currentInteractiveAnswered = Boolean( event?.id && pendingAnswers[event.id]?.text.trim(), ); + const eventTitleText = event?.title?.trim() || ""; const eventBodyText = event?.type === "quick_actions" - ? event.title || "Select an action" + ? eventTitleText || "Select an action" : event?.type === "message" ? event.message || "" : event?.question || ""; const eventContextMessage = event?.contextMessage?.trim() || ""; - const showContextMessage = + const normalizePopoverText = (value: string): string => + value + .toLowerCase() + .replace(/[`"'()[\]{}<>]/g, " ") + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .replace(/\s+/g, " ") + .trim(); + const normalizedBodyText = normalizePopoverText(eventBodyText); + const normalizedTitleText = normalizePopoverText(eventTitleText); + const normalizedContextText = normalizePopoverText(eventContextMessage); + const hasDistinctTitle = + !!eventTitleText && normalizedTitleText !== normalizedBodyText; + const hasDistinctContextMessage = !!eventContextMessage && - eventContextMessage.toLowerCase() !== eventBodyText.toLowerCase().trim(); - const showPromptInHeader = !event?.title && !!eventBodyText; + normalizedContextText !== normalizedBodyText && + normalizedContextText !== normalizedTitleText; + const showPromptInHeader = hasDistinctTitle; + const showContextMessage = hasDistinctContextMessage; + const showPromptInBody = !hasDistinctTitle || hasDistinctContextMessage; const capitalizeFirst = (str: string) => { if (!str) return str; @@ -2392,19 +2344,9 @@ export function InputWrapper() { ...(hasPendingQuestion ? { interactiveSubmit: true } : {}), }); - dispatch({ - type: "SET_MESSAGES", - payload: [ - ...messages, - { - role: "user", - created: Date.now(), - content: text, - parts: messageParts, - images: currentAttachments.map((a) => a.dataUrl), - }, - ], - }); + // TEMPORARY: do not optimistically render the outgoing user message. + // The conversation surface now waits for the centralized session tape so + // the UI stays aligned with the raw server-backed source of truth. dispatch({ type: "SET_PROCESSING", payload: true }); logger.info("[LOADING][INPUT] User sent message, dispatching SET_PROCESSING(true)", { sessionId: sessionId || null, @@ -2570,7 +2512,7 @@ export function InputWrapper() { dispatch({ type: "SET_INTERACTIVE_EVENTS", - payload: interactiveEvents, + payload: [], }); // Don't show processing state immediately - let extension confirm when actually processing @@ -2635,13 +2577,13 @@ export function InputWrapper() {
- {event.title ? ( -
- {event.title} -
- ) : null} - {displayInteractiveEvents.length > 1 && ( -
+ {showPromptInHeader ? ( +
+ {eventTitleText} +
+ ) : null} + {displayInteractiveEvents.length > 1 && ( +
- {showPromptInHeader ? ( + {showPromptInBody ? (
@@ -2721,14 +2663,14 @@ export function InputWrapper() { ))}
)} - {(showContextMessage || !showPromptInHeader) && ( + {(showContextMessage || showPromptInBody) && (
{showContextMessage ? (
) : null} - {!showPromptInHeader ? ( + {showPromptInBody && !hasDistinctContextMessage ? ( ) : null}
@@ -3250,7 +3192,7 @@ export function InputWrapper() { {/* Right: action buttons */}
- {isAiResponding && inputValue.trim().length === 0 ? ( + {isAiResponding ? ( +
+
+ ); + })()} +
+ ); +} diff --git a/webview/shared/src/chat/lib/messageHandler.ts b/webview/shared/src/chat/lib/messageHandler.ts index 0384f52..8caf548 100644 --- a/webview/shared/src/chat/lib/messageHandler.ts +++ b/webview/shared/src/chat/lib/messageHandler.ts @@ -45,7 +45,7 @@ import { import { config } from "../../config"; import vscode from "./vscode"; -const STREAM_DEBUG_ENABLED = true; +const STREAM_DEBUG_ENABLED = false; // Debouncing state for rendering snapshots let lastRenderLogTime = 0; @@ -3201,7 +3201,7 @@ function isLowSignalTimeoutFragment(content: string): boolean { return false; } -function normalizeInteractiveChoices(raw: unknown): InteractiveChoice[] { +function normalizeInteractiveChoices(raw: unknown): InteractiveChoice[] { let candidate = raw; if (typeof candidate === "string") { try { @@ -3230,14 +3230,30 @@ function normalizeInteractiveChoices(raw: unknown): InteractiveChoice[] { description: asString(rec.description) || asString(rec.detail) || undefined, } as InteractiveChoice; }) - .filter((item): item is InteractiveChoice => !!item); -} - -function normalizeToolInteractiveEvent( - record: UnknownRecord, - fallbackId: string, - fallbackTitle?: string, -): InteractiveEvent | undefined { + .filter((item): item is InteractiveChoice => !!item); +} + +function extractInteractivePromptText(record: UnknownRecord): string | undefined { + const nestedQuestion = asRecord(record.question); + return ( + asString(nestedQuestion?.displayPrompt) || + asString(nestedQuestion?.question) || + asString(nestedQuestion?.message) || + asString(nestedQuestion?.content) || + asString(record.displayPrompt) || + asString(record.question) || + asString(record.prompt) || + asString(record.message) || + asString(record.content) || + asString(record.text) + ) || undefined; +} + +function normalizeToolInteractiveEvent( + record: UnknownRecord, + fallbackId: string, + fallbackTitle?: string, +): InteractiveEvent | undefined { const id = asString(record.id) || fallbackId; const typeRaw = asString(record.type).toLowerCase(); const title = asString(record.title) || fallbackTitle || undefined; @@ -3257,20 +3273,22 @@ function normalizeToolInteractiveEvent( message, dismissLabel: asString(record.dismissLabel) || undefined, }; - } - - if (typeRaw === "quick_actions" || typeRaw === "quick-actions") { - const actions = normalizeInteractiveChoices(record.actions ?? record.options); - if (actions.length === 0) { - return undefined; - } - return { - type: "quick_actions", - id, - title, - actions, - }; - } + } + + if (typeRaw === "quick_actions" || typeRaw === "quick-actions") { + const actions = normalizeInteractiveChoices(record.actions ?? record.options); + if (actions.length === 0) { + return undefined; + } + const prompt = extractInteractivePromptText(record); + return { + type: "quick_actions", + id, + title: title || "Quick actions", + actions, + contextMessage: prompt && prompt !== title ? prompt : undefined, + }; + } const questionText = asString(record.question) || @@ -4354,7 +4372,7 @@ function pickBestContentCandidate( return best; } -function sanitizeAssistantMessageEcho(message: Message, state: AppState): Message { +function sanitizeAssistantMessageEcho(message: Message, state: AppState): Message { const role = message.role ?? message.info?.role; if (role !== 'assistant') { return message; @@ -4385,12 +4403,29 @@ function sanitizeAssistantMessageEcho(message: Message, state: AppState): Messag } } - return next; -} - -export function normalizeMessage(message: Message, streaming: StreamingState | null): Message | undefined { - const rec = asRecord(message); - if (!rec) { + return next; +} + +function cloneRawSnapshot(value: T): T { + if (typeof structuredClone === "function") { + try { + return structuredClone(value) as T; + } catch { + // Fall back to a shallow copy below. + } + } + if (Array.isArray(value)) { + return [...value] as T; + } + if (value && typeof value === "object") { + return { ...(value as Record) } as T; + } + return value; +} + +export function normalizeMessage(message: Message, streaming: StreamingState | null): Message | undefined { + const rec = asRecord(message); + if (!rec) { // FIX: If this is an assistant message with parts, don't return undefined // This prevents question-type messages from being filtered during hydration const role = asString(message.role); @@ -4399,9 +4434,14 @@ export function normalizeMessage(message: Message, streaming: StreamingState | n return message as Message; // Preserve assistant messages with parts } return streaming ? buildStreamingMessage(streaming) : undefined; - } - - const parts = Array.isArray(rec.parts) ? rec.parts : []; + } + + const rawSdkEventPayloadsSnapshot = Array.isArray(streaming?.rawSdkEventPayloads) + ? cloneRawSnapshot(streaming.rawSdkEventPayloads) + : Array.isArray(rec.rawSdkEventPayloads) + ? cloneRawSnapshot(rec.rawSdkEventPayloads) + : undefined; + const parts = Array.isArray(rec.parts) ? rec.parts : []; const parsedRawDebug = parseRawResponseDebug(rec.rawResponse); const mergedParts = [...parts]; const currentReasoning = reasoningFromParts(mergedParts); @@ -4716,18 +4756,21 @@ export function normalizeMessage(message: Message, streaming: StreamingState | n rawPartsCount: parsedRawDebug.parts.length, }); } - const normalized: Message = { - ...(message as Message), - role: role || message.role || (parts.length > 0 ? 'assistant' : undefined), - content: shouldUseStreamingContent ? streamingContent : content || message.content, - parts: shouldUseStreamingContent + const normalized: Message = { + ...(message as Message), + role: role || message.role || (parts.length > 0 ? 'assistant' : undefined), + content: shouldUseStreamingContent ? streamingContent : content || message.content, + parts: shouldUseStreamingContent ? partsWithStreamingContent(normalizedParts, streamingContent) - : normalizedParts.length > 0 - ? (normalizedParts as Message['parts']) - : message.parts - }; - - const normalizedInfoRecord = asRecord(normalized.info) || {}; + : normalizedParts.length > 0 + ? (normalizedParts as Message['parts']) + : message.parts + }; + if (rawSdkEventPayloadsSnapshot) { + (normalized as Record).rawSdkEventPayloads = rawSdkEventPayloadsSnapshot; + } + + const normalizedInfoRecord = asRecord(normalized.info) || {}; const variant = firstNonEmptyString( asString(normalizedInfoRecord.variant), asString(rec.variant), @@ -5318,9 +5361,9 @@ function normalizeSubagentDetail(value: unknown): SubagentDetail | null { .filter((entry): entry is SubagentThinkingEvent => !!entry) : []; - const conversationEvents = Array.isArray(rec.conversationEvents) - ? rec.conversationEvents - .map((entry, index) => { + const conversationEvents = Array.isArray(rec.conversationEvents) + ? rec.conversationEvents + .map((entry, index) => { const evt = asRecord(entry); if (!evt) { return null; @@ -5343,9 +5386,12 @@ function normalizeSubagentDetail(value: unknown): SubagentDetail | null { messageID: asString(evt.messageID) || undefined, partID: asString(evt.partID) || undefined, }; - }) - .filter((entry): entry is SubagentConversationEvent => !!entry) - : []; + }) + .filter((entry): entry is SubagentConversationEvent => !!entry) + : []; + const rawConversationEvents = Array.isArray(rec.conversationEvents) + ? [...rec.conversationEvents] + : []; const progressEvents = Array.isArray(rec.progressEvents) ? rec.progressEvents @@ -5411,10 +5457,11 @@ function normalizeSubagentDetail(value: unknown): SubagentDetail | null { return { ...summary, - thinkingEvents, - conversationEvents, - progressEvents: normalizedProgressEvents, - timelineEvents: normalizedTimelineEvents, + thinkingEvents, + conversationEvents, + rawConversationEvents, + progressEvents: normalizedProgressEvents, + timelineEvents: normalizedTimelineEvents, tokenUsage: tokenUsageRec ? { input: asOptionalNumber(tokenUsageRec.input), @@ -5516,9 +5563,9 @@ function hydrateSubagentSummary( timelineEvents: [], }; } - return { - ...summary, - ...detail, + return { + ...summary, + ...detail, parentSessionId: detail.parentSessionId || summary.parentSessionId, parentMessageId: detail.parentMessageId || summary.parentMessageId, status: detail.status || summary.status, @@ -5530,12 +5577,17 @@ function hydrateSubagentSummary( thinkingEvents: Array.isArray(detail.thinkingEvents) ? detail.thinkingEvents : [], - conversationEvents: Array.isArray(detail.conversationEvents) - ? detail.conversationEvents - : [], - progressEvents: Array.isArray(detail.progressEvents) - ? detail.progressEvents - : [], + conversationEvents: Array.isArray(detail.conversationEvents) + ? detail.conversationEvents + : [], + rawConversationEvents: Array.isArray(detail.rawConversationEvents) + ? detail.rawConversationEvents + : Array.isArray(detail.conversationEvents) + ? detail.conversationEvents + : [], + progressEvents: Array.isArray(detail.progressEvents) + ? detail.progressEvents + : [], timelineEvents: Array.isArray(detail.timelineEvents) ? detail.timelineEvents : [], @@ -5667,11 +5719,11 @@ function normalizeHydratedSubagentDetail( ): SubagentDetail { const cleanedLatestActivity = sanitizeSubagentLabel(detail.latestActivity) || "Subagent update"; - const normalizedForPresentation: SubagentDetail = { - ...detail, - latestActivity: cleanedLatestActivity, - progressEvents: normalizeSubagentProgressEventsForPresentation( - Array.isArray(detail.progressEvents) ? detail.progressEvents : [], + const normalizedForPresentation: SubagentDetail = { + ...detail, + latestActivity: cleanedLatestActivity, + progressEvents: normalizeSubagentProgressEventsForPresentation( + Array.isArray(detail.progressEvents) ? detail.progressEvents : [], ), timelineEvents: normalizeSubagentTimelineEventsForPresentation( Array.isArray(detail.timelineEvents) ? detail.timelineEvents : [], @@ -5878,18 +5930,18 @@ function deriveSessionIdFromMessage( return fallbackSessionId; } -function getMessageId(message: Message): string | null { - return ( - asString(asRecord(message.info)?.id) || - asString((message as unknown as UnknownRecord).id) || - null - ); -} - -function mergeAssistantActivitySteps( - existing: MessageStep[] | undefined, - incoming: MessageStep[] | undefined, -): MessageStep[] | undefined { +function getMessageId(message: Message): string | null { + return ( + asString(asRecord(message.info)?.id) || + asString((message as unknown as UnknownRecord).id) || + null + ); +} + +function mergeAssistantActivitySteps( + existing: MessageStep[] | undefined, + incoming: MessageStep[] | undefined, +): MessageStep[] | undefined { const existingSteps = Array.isArray(existing) ? existing : []; const incomingSteps = Array.isArray(incoming) ? incoming : []; if (existingSteps.length === 0) { @@ -6338,16 +6390,27 @@ function mergeSubagentDetailRecord( (event, index) => event.id || `${event.createdAt || 0}:${event.text || ""}:${index}`, ); - const conversationEvents = mergeUniqueSubagentEntries( - existing?.conversationEvents, - incoming.conversationEvents, - (event, index) => - event.id || - `${event.role || ""}:${event.kind || ""}:${event.createdAt || 0}:${event.text || ""}:${index}`, - ); - const progressEvents = normalizeSubagentProgressEventsForPresentation( - mergeUniqueSubagentEntries( - existing?.progressEvents, + const conversationEvents = mergeUniqueSubagentEntries( + existing?.conversationEvents, + incoming.conversationEvents, + (event, index) => + event.id || + `${event.role || ""}:${event.kind || ""}:${event.createdAt || 0}:${event.text || ""}:${index}`, + ); + const rawConversationEvents = mergeUniqueSubagentEntries( + existing?.rawConversationEvents, + incoming.rawConversationEvents, + (event, index) => { + const rec = asRecord(event); + return ( + asString(rec?.id) || + `${asString(rec?.messageID) || asString(rec?.messageId) || ""}:${asString(rec?.partID) || asString(rec?.partId) || ""}:${asString(rec?.kind) || asString(rec?.role) || ""}:${asString(rec?.text) || asString(rec?.content) || ""}:${asNumber(rec?.createdAt)}:${index}` + ); + }, + ); + const progressEvents = normalizeSubagentProgressEventsForPresentation( + mergeUniqueSubagentEntries( + existing?.progressEvents, incoming.progressEvents, (event, index) => event.callID || @@ -6376,11 +6439,12 @@ function mergeSubagentDetailRecord( status: incoming.status || existing?.status || "pending", latestActivity, references, - thinkingEvents, - conversationEvents, - progressEvents, - timelineEvents, - }; + thinkingEvents, + conversationEvents, + rawConversationEvents, + progressEvents, + timelineEvents, + }; } function mergeSubagentDetailPayload( @@ -6885,30 +6949,10 @@ function isTextLikePart(part: MessagePart): boolean { ); } -/** - * Coalesce an adjacent assistant-message burst into a single message. - * - * CRITICAL — evt_ vs msg_ merge base selection: - * `mergeAssistantReplacement` calls this with `[existing, incoming]` where - * the incoming message often carries an `evt_` lifecycle ID while the existing - * message carries the authoritative `msg_` ID. Always prefer the last non-evt - * message as the merge base so that canonical fields (`content`, `text`, - * `structuredOutput.message`) come from the `msg_` record. The `evt_` entry - * only contributes metadata (steps, reasoning events, edits, interactive - * events). This prevents garbled event-fragment text from overwriting valid - * assistant responses while still folding in all lifecycle state. - * - * This mirrors the same pattern in `coalesceAssistantRunForCanonical` (store.ts). - */ -function coalesceAssistantHistoryBurst(burst: Message[]): Message { - const preferredIdx = burst.length > 1 - ? burst.findLastIndex( - (m) => !(typeof getMessageId(m) === "string" && getMessageId(m)?.startsWith("evt_")), - ) - : -1; - const base = { - ...(preferredIdx >= 0 ? burst[preferredIdx] : burst[burst.length - 1] || burst[0]), - } as Message; +function coalesceAssistantHistoryBurst(burst: Message[]): Message { + const base = { + ...(burst[burst.length - 1] || burst[0]), + } as Message; const mergedParts: MessagePart[] = []; const seenPartFingerprints = new Set(); const seenReasoning = new Set(); @@ -6920,14 +6964,19 @@ function coalesceAssistantHistoryBurst(burst: Message[]): Message { let latestTextScore = 0; let latestTextPart: MessagePart | undefined; let latestInteractiveEvents: InteractiveEvent[] | undefined; - let latestPlan = base.plan; - const subagentsByMessageId = new Map(); - let latestSubagentsWithoutMessageId: Message["subagents"] | undefined; - let latestError = asString((base as unknown as UnknownRecord).error); - let latestRawResponse: unknown = (base as unknown as UnknownRecord).rawResponse; - let latestStructuredOutput = asRecord( - (base as unknown as UnknownRecord).structuredOutput, - ); + let latestPlan = base.plan; + const subagentsByMessageId = new Map(); + let latestSubagentsWithoutMessageId: Message["subagents"] | undefined; + let latestError = asString((base as unknown as UnknownRecord).error); + let latestRawResponse: unknown = (base as unknown as UnknownRecord).rawResponse; + let latestRawSdkEventPayloads: unknown[] | undefined = Array.isArray( + (base as unknown as UnknownRecord).rawSdkEventPayloads, + ) + ? [...((base as unknown as UnknownRecord).rawSdkEventPayloads as unknown[])] + : undefined; + let latestStructuredOutput = asRecord( + (base as unknown as UnknownRecord).structuredOutput, + ); const mergeStructuredOutputRecords = ( existing: UnknownRecord | null, incoming: UnknownRecord | null, @@ -7017,31 +7066,24 @@ function coalesceAssistantHistoryBurst(burst: Message[]): Message { base.edits = [...list, edit]; }; - for (const message of burst) { - if (!message || typeof message !== "object") { - continue; - } - - const messageId = getMessageId(message); - if (messageId) { - const incomingIsEvt = typeof messageId === "string" && messageId.startsWith("evt_"); - const currentIsNonEvt = typeof canonicalMessageId === "string" && !canonicalMessageId.startsWith("evt_"); - if (!incomingIsEvt || !currentIsNonEvt) { - canonicalMessageId = messageId; - } - } - - const content = extractRenderableAssistantTextForHydration(message).trim(); - if (content.length > 0) { - const structuredMessage = getCanonicalStructuredMessageText(message); - const incomingIsEvt = typeof messageId === "string" && messageId.startsWith("evt_"); - const currentIsNonEvt = typeof canonicalMessageId === "string" && !canonicalMessageId.startsWith("evt_"); - const candidateTextScore = - content.length + (isCanonicalAssistantDisplayMessage(message) ? 100000 : 0); - if (candidateTextScore >= latestTextScore && (!incomingIsEvt || !currentIsNonEvt)) { - latestTextScore = candidateTextScore; - latestText = content; - latestTextPart = Array.isArray(message.parts) + for (const message of burst) { + if (!message || typeof message !== "object") { + continue; + } + + const messageId = getMessageId(message); + if (messageId) { + canonicalMessageId = messageId; + } + + const content = extractRenderableAssistantTextForHydration(message).trim(); + if (content.length > 0) { + const candidateTextScore = + content.length + (isCanonicalAssistantDisplayMessage(message) ? 100000 : 0); + if (candidateTextScore >= latestTextScore) { + latestTextScore = candidateTextScore; + latestText = content; + latestTextPart = Array.isArray(message.parts) ? message.parts.find((part) => { const rec = asRecord(part); return !!rec && isRenderableAssistantTextPart(rec); @@ -7070,16 +7112,22 @@ function coalesceAssistantHistoryBurst(burst: Message[]): Message { if (errorText) { latestError = errorText; } - if (typeof message.rawResponse === "string") { - if (message.rawResponse.trim().length > 0) { - latestRawResponse = message.rawResponse; - } - } else if (typeof message.rawResponse !== "undefined") { - latestRawResponse = message.rawResponse; - } - const structured = asRecord( - (message as unknown as UnknownRecord).structuredOutput, - ); + if (typeof message.rawResponse === "string") { + if (message.rawResponse.trim().length > 0) { + latestRawResponse = message.rawResponse; + } + } else if (typeof message.rawResponse !== "undefined") { + latestRawResponse = message.rawResponse; + } + if (Array.isArray((message as unknown as UnknownRecord).rawSdkEventPayloads)) { + const rawSdkEventPayloads = (message as unknown as UnknownRecord).rawSdkEventPayloads as unknown[]; + if (rawSdkEventPayloads.length > 0) { + latestRawSdkEventPayloads = [...rawSdkEventPayloads]; + } + } + const structured = asRecord( + (message as unknown as UnknownRecord).structuredOutput, + ); if (structured) { latestStructuredOutput = mergeStructuredOutputRecords( latestStructuredOutput, @@ -7185,11 +7233,14 @@ function coalesceAssistantHistoryBurst(burst: Message[]): Message { if (latestStructuredOutput) { (base as unknown as UnknownRecord).structuredOutput = latestStructuredOutput; } - if (typeof latestRawResponse !== "undefined") { - (base as unknown as UnknownRecord).rawResponse = latestRawResponse; - } - - if (canonicalMessageId) { + if (typeof latestRawResponse !== "undefined") { + (base as unknown as UnknownRecord).rawResponse = latestRawResponse; + } + if (Array.isArray(latestRawSdkEventPayloads) && latestRawSdkEventPayloads.length > 0) { + (base as unknown as UnknownRecord).rawSdkEventPayloads = latestRawSdkEventPayloads; + } + + if (canonicalMessageId) { base.id = canonicalMessageId; const infoRec = asRecord(base.info); base.info = infoRec @@ -7251,56 +7302,25 @@ function summarizeRenderMessageForDebug(message: Message, index: number): Record }; } -function logRenderSnapshot(source: string, messages: Message[]): void { - const now = Date.now(); - if (now - lastRenderLogTime < RENDER_LOG_DEBOUNCE_MS) { - return; // Skip logging if too soon since last render log - } - lastRenderLogTime = now; - - const tail = messages.slice(-20); - const summary = tail.map((message, index) => - summarizeRenderMessageForDebug(message, messages.length - tail.length + index), - ); - const last = summary[summary.length - 1]; - logger.info("[PRE-RENDER] rendering snapshot", { - source, - messageCount: messages.length, - last, - }); -} - -function logSourceSnapshot(source: string, messages: Message[] | unknown[]): void { - const normalizedMessages = Array.isArray(messages) ? messages : []; - const tail = normalizedMessages.slice(-20); - const summary = tail.map((message, index) => - summarizeRenderMessageForDebug( - message as Message, - normalizedMessages.length - tail.length + index, - ), - ); - const last = summary[summary.length - 1]; - logger.info("[SOURCE] incoming render payload", { - source, - messageCount: normalizedMessages.length, - last, - }); -} +function logRenderSnapshot(source: string, messages: Message[]): void { + void source; + void messages; +} + +function logSourceSnapshot(source: string, messages: Message[] | unknown[]): void { + void source; + void messages; +} function logFullPayloadSnapshot( stage: "SOURCE" | "PRE-RENDER", source: string, payload: Record, -): void { - const messages = payload.messages as unknown[]; - const messageCount = Array.isArray(messages) ? messages.length : 0; - - logger.info(`[${stage}] payload snapshot`, { - source, - messageCount, - keys: Object.keys(payload).filter(k => k !== 'messages') - }); -} +): void { + void stage; + void source; + void payload; +} function stripChoicePrefix(input: string): string { return input @@ -8110,11 +8130,11 @@ export function dedupePlanProceedMessages(messages: Message[]): Message[] { return deduped; } -function buildStreamingMessage(streaming: StreamingState): Message { - const parts: any[] = [ - { - type: "text", - text: streaming.content, +function buildStreamingMessage(streaming: StreamingState): Message { + const parts: any[] = [ + { + type: "text", + text: streaming.content, }, ]; if (streaming.reasoning) { @@ -8132,21 +8152,22 @@ function buildStreamingMessage(streaming: StreamingState): Message { parts as MessagePart[], ); - return { - role: "assistant", - responseType: streaming.responseType, - content: streaming.content, - parts, - plan: streaming.plan, - reasoningEvents: streaming.reasoningEvents, - progressEvents: canonicalSteps, - steps: canonicalSteps, - edits: streaming.edits.map((file) => ({ file })), - interactiveEvents: streaming.interactiveEvents, - structuredOutput: streaming.structuredOutput, - info: { - id: streaming.messageId ?? undefined, - agent: streaming.agent, + return { + role: "assistant", + responseType: streaming.responseType, + content: streaming.content, + parts, + plan: streaming.plan, + reasoningEvents: streaming.reasoningEvents, + progressEvents: canonicalSteps, + steps: canonicalSteps, + edits: streaming.edits.map((file) => ({ file })), + interactiveEvents: streaming.interactiveEvents, + structuredOutput: streaming.structuredOutput, + rawSdkEventPayloads: streaming.rawSdkEventPayloads, + info: { + id: streaming.messageId ?? undefined, + agent: streaming.agent, model: streaming.model, modelID: streaming.modelID, providerID: streaming.providerID, @@ -8157,20 +8178,21 @@ function buildStreamingMessage(streaming: StreamingState): Message { }; } -function hasVisibleStreamingSnapshot(streaming: StreamingState | null | undefined): streaming is StreamingState { - if (!streaming) { - return false; - } - return ( +function hasVisibleStreamingSnapshot(streaming: StreamingState | null | undefined): streaming is StreamingState { + if (!streaming) { + return false; + } + return ( asString(streaming.content).trim().length > 0 || asString(streaming.reasoning).trim().length > 0 || (Array.isArray(streaming.reasoningEvents) && streaming.reasoningEvents.length > 0) || (Array.isArray(streaming.progressEvents) && streaming.progressEvents.length > 0) || - (Array.isArray(streaming.steps) && streaming.steps.length > 0) || - (Array.isArray(streaming.edits) && streaming.edits.length > 0) || - (Array.isArray(streaming.interactiveEvents) && streaming.interactiveEvents.length > 0) - ); -} + (Array.isArray(streaming.steps) && streaming.steps.length > 0) || + (Array.isArray(streaming.edits) && streaming.edits.length > 0) || + (Array.isArray(streaming.interactiveEvents) && streaming.interactiveEvents.length > 0) || + (Array.isArray(streaming.rawSdkEventPayloads) && streaming.rawSdkEventPayloads.length > 0) + ); +} function activityScoreFromMessages(messages: Message[]): number { return messages.reduce((score, message) => { @@ -8264,80 +8286,18 @@ export function shouldPreferCachedSwitchMessages( return false; } -function mergeStreamingSnapshotIntoHistory( - messages: Message[], - streaming: StreamingState, -): Message[] { - const streamingMessage = buildStreamingMessage(streaming); +function mergeStreamingSnapshotIntoHistory( + messages: Message[], + streaming: StreamingState, +): Message[] { + const streamingMessage = buildStreamingMessage(streaming); const streamingMessageId = - streaming.messageId || - asString(asRecord(streamingMessage.info)?.id) || - asString(streamingMessage.id) || - null; - const candidateIds: Array = [streamingMessageId]; - - // Providers use evt_-prefixed message IDs for lifecycle events (streaming - // start/finish) that can land after the authoritative msg_ message. Without - // text-match fallback these evt_ entries would be appended as duplicate - // assistant cards and coalesce into the visible response with stale text. - // Expand the text-match path to cover missing IDs AND evt_-prefixed IDs so - // the evt_ entry replaces the existing non-evt message instead of forking - // the assistant run. - const shouldTryTextMatch = - !streamingMessageId || - (typeof streamingMessageId === "string" && streamingMessageId.startsWith("evt_")); - - if (shouldTryTextMatch) { - const incomingText = normalizeComparableText( - extractMessageText(streamingMessage), - ); - if (incomingText.length > 0) { - let lastUserIndex = -1; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (!message) { - continue; - } - const role = - (asString(message.role) || asString(asRecord(message.info)?.role)) - .toLowerCase() - .trim(); - if (role === "user") { - lastUserIndex = index; - break; - } - } - - for (let index = messages.length - 1; index > lastUserIndex; index -= 1) { - const candidate = messages[index]; - if (!isAssistantHistoryMessage(candidate)) { - continue; - } - const candidateId = getMessageId(candidate); - const isNonEvtCandidate = - typeof candidateId === "string" && !candidateId.startsWith("evt_"); - // When the incoming snapshot has an evt_ ID, prefer latching onto the - // first non-evt assistant message (msg_), even without text matching. - // This prevents the evt_ entry from forking the assistant run and - // keeps the canonical response text surfaced by the msg_ card. - if (isNonEvtCandidate) { - candidateIds.unshift(candidateId); - break; - } - const candidateText = normalizeComparableText(extractMessageText(candidate)); - if (!candidateText || candidateText !== incomingText) { - continue; - } - if (candidateId) { - candidateIds.push(candidateId); - break; - } - } - } - } - - return replaceMatchingAssistantTurn(messages, streamingMessage, candidateIds); -} + streaming.messageId || + asString(asRecord(streamingMessage.info)?.id) || + asString(streamingMessage.id) || + null; + return replaceMatchingAssistantTurn(messages, streamingMessage, [streamingMessageId]); +} function flushVisibleStreamingSnapshotToMessages( dispatch: Dispatch, @@ -8453,35 +8413,43 @@ function handleStreamEvent( const isPartUpdateEvent = eventType.startsWith("message.part."); const normalizedEventType = isPartUpdateEvent ? "message.part.updated" : eventType; - const isHeartbeatEvent = isHeartbeatEventType(eventType); - if (!isHeartbeatEvent && !terminalErrorReached) { - const capturePayload: Record = { type: eventType }; - if (payload.properties) { - capturePayload.properties = { ...payload.properties }; - } - if (payload.part) { - capturePayload.part = { ...payload.part }; - } - if (payload.info) { - capturePayload.info = { ...payload.info }; - } - if (payload.structured) { - capturePayload.structured = { ...payload.structured }; - } - if (payload.text) { - capturePayload.text = payload.text; - } - if (payload.id || payload.messageId || (payload as UnknownRecord).messageID) { - capturePayload.id = payload.id || payload.messageId || (payload as UnknownRecord).messageID; - } - if (payload.sessionId || payload.sessionID) { - capturePayload.sessionId = payload.sessionId || payload.sessionID; - } - dispatch({ type: "APPEND_SDK_EVENT_PAYLOAD", payload: capturePayload }); - } - const state = getState(); - const current = state.streaming; - const properties = asRecord(payload.properties); + const isHeartbeatEvent = isHeartbeatEventType(eventType); + if (!isHeartbeatEvent && !terminalErrorReached) { + dispatch({ + type: "APPEND_RAW_SDK_EVENT_PAYLOAD", + payload: { + sessionId: asString(payload.sessionId) || asString(payload.sessionID) || null, + event: payload, + }, + }); + dispatch({ type: "APPEND_SDK_EVENT_PAYLOAD", payload }); + } + const state = getState(); + const current = state.streaming; + console.info("[TRACE][HANDLER][RAW_EVENT_INGRESS]", { + eventType, + messageId: + asString(payload.messageId) || + asString((payload as UnknownRecord).messageID) || + asString(payload.id) || + asString(asRecord(payload.properties)?.messageId) || + asString(asRecord(payload.properties)?.messageID) || + null, + sessionId: + asString(payload.sessionId) || + asString((payload as UnknownRecord).sessionID) || + asString(asRecord(payload.properties)?.sessionId) || + asString(asRecord(payload.properties)?.sessionID) || + null, + streamingMessageId: current?.messageId ?? null, + streamingActive: !!current?.isActive, + streamingInteractiveCount: Array.isArray(current?.interactiveEvents) + ? current.interactiveEvents.length + : 0, + payloadKeys: Object.keys(payload), + propertyKeys: Object.keys(asRecord(payload.properties) || {}), + }); + const properties = asRecord(payload.properties); const partRecord = asRecord(properties?.part); const infoRecord = asRecord(payload.info) ?? asRecord(properties?.info); const eventPart = @@ -8979,14 +8947,32 @@ function handleStreamEvent( const interactiveEvents = toInteractiveEvents(structuredOutput); const hasBlockingInteractive = hasBlockingInteractiveEvents(interactiveEvents); - if (interactiveEvents.length > 0) { - dispatch({ type: "SET_INTERACTIVE_EVENTS", payload: interactiveEvents }); - maybeInjectStreamingInteractiveContext( - dispatch, - getState, - interactiveEvents, - ); - } + if (interactiveEvents.length > 0) { + dispatch({ type: "SET_INTERACTIVE_EVENTS", payload: interactiveEvents }); + maybeInjectStreamingInteractiveContext( + dispatch, + getState, + interactiveEvents, + ); + logger.info("[TRACE][HANDLER][PART_INTERACTIVE_EVENTS]", { + eventType: normalizedEventType, + messageId, + structuredKind: currentStructuredKind || null, + interactiveCount: interactiveEvents.length, + blockingInteractive: hasBlockingInteractive, + streamingExists: !!getState().streaming, + streamingInteractiveCount: getState().streaming?.interactiveEvents?.length ?? 0, + }); + console.info("[TRACE][HANDLER][PART_INTERACTIVE_EVENTS]", { + eventType: normalizedEventType, + messageId, + structuredKind: currentStructuredKind || null, + interactiveCount: interactiveEvents.length, + blockingInteractive: hasBlockingInteractive, + streamingExists: !!getState().streaming, + streamingInteractiveCount: getState().streaming?.interactiveEvents?.length ?? 0, + }); + } if (structuredOutput?.subagents || structuredOutput?.subagentsDelta) { logger.info('[SUBAGENT-DEBUG] stream message.part.updated dispatching subagents', { @@ -9565,22 +9551,43 @@ function handleStreamEvent( !!updatedText.trim() || !!structuredText.trim() || liveStructuredInteractiveEvents.length > 0; - if ( - structuredKind === "lifecycle" && - !finish && - !hasRenderableLiveStructuredUpdate - ) { + if ( + structuredKind === "lifecycle" && + !finish && + !hasRenderableLiveStructuredUpdate + ) { streamDebug("Stream: suppressing lifecycle-only message update", { messageId, eventRole: eventRole || null, structuredKind, finish, currentStreaming: summarizeStreamingForLog(getState().streaming), - }); - break; - } - - if (finish && structuredOutput) { + }); + break; + } + + logger.info("[TRACE][HANDLER][MESSAGE_UPDATED_STATE]", { + messageId, + finish, + structuredKind: structuredKind || null, + hasRenderableLiveStructuredUpdate, + liveInteractiveCount: liveStructuredInteractiveEvents.length, + currentStreamingMessageId: getState().streaming?.messageId ?? null, + currentStreamingInteractiveCount: getState().streaming?.interactiveEvents?.length ?? 0, + streamingExists: !!getState().streaming, + }); + console.info("[TRACE][HANDLER][MESSAGE_UPDATED_STATE]", { + messageId, + finish, + structuredKind: structuredKind || null, + hasRenderableLiveStructuredUpdate, + liveInteractiveCount: liveStructuredInteractiveEvents.length, + currentStreamingMessageId: getState().streaming?.messageId ?? null, + currentStreamingInteractiveCount: getState().streaming?.interactiveEvents?.length ?? 0, + streamingExists: !!getState().streaming, + }); + + if (finish && structuredOutput) { if (structuredOutput.reasoning) { structuredOutput.reasoning.forEach((chunk) => { const sanitized = sanitizeReasoningChunk(chunk); @@ -9738,39 +9745,24 @@ function handleStreamEvent( - // Lifecycle events that carry the final structured message text may not - // include an explicit finish signal from the server. When a lifecycle - // message.updated delivers renderable text, treat it as a terminal signal - // so the loading state transitions to complete instead of staying stuck. - if (finish || (structuredKind === "lifecycle" && hasRenderableLiveStructuredUpdate)) { - const effectiveFinish = structuredKind === "lifecycle" && !finish; - if (effectiveFinish) { - streamDebug("Stream: lifecycle event with renderable update treated as terminal", { - messageId, - structuredKind, - hasRenderableLiveStructuredUpdate, - currentStreaming: summarizeStreamingForLog(getState().streaming), - }); - - // Lifecycle events carry the final message text in structured output - // rather than in streaming.content. Inject it into the streaming - // snapshot so the flush creates a message with the correct body. - const streamingNow = getState().streaming; - if (streamingNow && structuredText) { - const streamingOverride: StreamingState = { - ...streamingNow, - content: structuredText, - structuredOutput: structuredOutput as unknown as StreamingState["structuredOutput"], - responseType: (structuredOutput?.responseType || "message") as StructuredResponseType, - }; - flushVisibleStreamingSnapshotToMessages(dispatch, getState, streamingOverride); - } - } - const terminalStatus: "done" | "error" = - asString(asRecord(payload.info)?.error) || - asString(asRecord(properties)?.error) - ? "error" - : "done"; + if (finish) { + if (structuredText) { + const streamingNow = getState().streaming; + if (streamingNow) { + const streamingOverride: StreamingState = { + ...streamingNow, + content: structuredText, + structuredOutput: structuredOutput as unknown as StreamingState["structuredOutput"], + responseType: (structuredOutput?.responseType || "message") as StructuredResponseType, + }; + flushVisibleStreamingSnapshotToMessages(dispatch, getState, streamingOverride); + } + } + const terminalStatus: "done" | "error" = + asString(asRecord(payload.info)?.error) || + asString(asRecord(properties)?.error) + ? "error" + : "done"; const finalized = finalizeStreamingSnapshotSteps( getState().streaming, terminalStatus, @@ -9783,21 +9775,21 @@ function handleStreamEvent( // Lifecycle events carry the correct final text in // structured output, not streaming.content. Inject it // so subsequent state reads reflect the real content. - ...(effectiveFinish && structuredText - ? { content: structuredText } - : {}), - hasAssistantFinishSignal: true, - }, - }); - } - dispatch({ - type: "SET_ASSISTANT_TURN_PENDING", - payload: { pending: false, messageId: null }, - }); - dispatch({ type: 'SET_PROCESSING', payload: false }); - dispatch({ type: 'FINISH_STREAMING' }); - } else { - dispatch({ type: 'SET_PROCESSING', payload: true }); + ...(effectiveFinish && structuredText + ? { content: structuredText } + : {}), + hasAssistantFinishSignal: true, + }, + }); + } + dispatch({ + type: "SET_ASSISTANT_TURN_PENDING", + payload: { pending: false, messageId: null }, + }); + dispatch({ type: 'SET_PROCESSING', payload: false }); + dispatch({ type: 'FINISH_STREAMING' }); + } else { + dispatch({ type: 'SET_PROCESSING', payload: true }); } break; } @@ -10211,15 +10203,33 @@ function handleStreamEvent( const interactiveEvents = toInteractiveEvents(structuredOutput); const hasBlockingInteractive = hasBlockingInteractiveEvents(interactiveEvents); - if (interactiveEvents.length > 0) { - dispatch({ type: "SET_INTERACTIVE_EVENTS", payload: interactiveEvents }); - maybeInjectStreamingInteractiveContext( - dispatch, - getState, - interactiveEvents, - ); - consumed = true; - } + if (interactiveEvents.length > 0) { + dispatch({ type: "SET_INTERACTIVE_EVENTS", payload: interactiveEvents }); + maybeInjectStreamingInteractiveContext( + dispatch, + getState, + interactiveEvents, + ); + logger.info("[TRACE][HANDLER][STRUCTURED_INTERACTIVE_EVENTS]", { + eventType: normalizedEventType, + messageId, + structuredKind: structuredKind || null, + interactiveCount: interactiveEvents.length, + blockingInteractive: hasBlockingInteractive, + streamingExists: !!getState().streaming, + streamingInteractiveCount: getState().streaming?.interactiveEvents?.length ?? 0, + }); + console.info("[TRACE][HANDLER][STRUCTURED_INTERACTIVE_EVENTS]", { + eventType: normalizedEventType, + messageId, + structuredKind: structuredKind || null, + interactiveCount: interactiveEvents.length, + blockingInteractive: hasBlockingInteractive, + streamingExists: !!getState().streaming, + streamingInteractiveCount: getState().streaming?.interactiveEvents?.length ?? 0, + }); + consumed = true; + } if (structuredOutput?.subagents || structuredOutput?.subagentsDelta) { logger.info('[SUBAGENT-DEBUG] stream default case dispatching subagents', { @@ -11195,20 +11205,19 @@ export function createMessageHandler(dispatch: Dispatch, getState: () ), }); - const responseMessageId = - asString(msg.id) || asString(asRecord(msg.info)?.id); - const currentStateForResponse = getState(); - const currentStreaming = currentStateForResponse.streaming; - const snapshotMessageId = latestStreamingSnapshot?.messageId || null; - const hasOwnResponsePayload = - !!asString(msg.content).trim() || - !!asString(msg.text).trim() || - (Array.isArray(msg.parts) && msg.parts.length > 0); - const shouldDropMismatchedSnapshot = - !!responseMessageId && - !!snapshotMessageId && - snapshotMessageId !== responseMessageId && - hasOwnResponsePayload; + const responseMessageId = getMessageId(msg); + const currentStateForResponse = getState(); + const currentStreaming = currentStateForResponse.streaming; + const snapshotMessageId = latestStreamingSnapshot?.messageId || null; + const hasOwnResponsePayload = + !!asString(msg.content).trim() || + !!asString(msg.text).trim() || + (Array.isArray(msg.parts) && msg.parts.length > 0); + const shouldDropMismatchedSnapshot = + !!responseMessageId && + !!snapshotMessageId && + snapshotMessageId !== responseMessageId && + hasOwnResponsePayload; if ( !currentStreaming && latestStreamingSnapshot && @@ -11433,14 +11442,15 @@ export function createMessageHandler(dispatch: Dispatch, getState: () sanitized, getState().currentSessionId, ); - if (sessionId) { - vscode.postMessage({ - type: "persistAssistantMessage", - sessionId, - message: sanitized, - }); - } - } + if (sessionId) { + vscode.postMessage({ + type: "persistAssistantMessage", + sessionId, + message: sanitized, + rawMessage: msg, + }); + } + } const isMatchingStreamingMessage = streamingMessageId && finalMessageId && streamingMessageId === finalMessageId; // Only clear streaming state if this messageResponse matches the @@ -11495,24 +11505,39 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }; try { terminalErrorReached = false; - const rawMessages = asArray(data.messages, isMessage); - const historySessionId = asString(data.sessionId) || null; - if (historySessionId) { - logger.setSession(historySessionId); - } - logSourceSnapshot("chatHistory", rawMessages); - logFullPayloadSnapshot("SOURCE", "chatHistory", { - sessionId: historySessionId, - messages: rawMessages, - processingSessionIds: asArray( - data.processingSessionIds, - (item): item is string => typeof item === "string", - ), - }); - const normalizedMessages = rawMessages - .map((msg) => normalizeMessage(msg, null)) - .filter((msg): msg is Message => !!msg) - .filter((msg) => isRenderableHistoryMessage(msg)); + const rawMessages = asArray(data.messages, isMessage); + const rawHistoryMessages = Array.isArray((data as UnknownRecord).rawMessages) + ? [...((data as UnknownRecord).rawMessages as unknown[])] + : []; + const rawSdkEventPayloads = Array.isArray((data as UnknownRecord).rawSdkEventPayloads) + ? [...((data as UnknownRecord).rawSdkEventPayloads as unknown[])] + : []; + const historySessionId = asString(data.sessionId) || null; + if (historySessionId) { + logger.setSession(historySessionId); + } + const sourceMessages = + rawHistoryMessages.length > 0 ? rawHistoryMessages : rawMessages; + dispatch({ + type: "SET_RAW_MESSAGES", + payload: { + sessionId: historySessionId || asString(data.sessionId) || "", + messages: sourceMessages, + }, + }); + if (rawSdkEventPayloads.length > 0) { + dispatch({ + type: "SET_RAW_SDK_EVENT_PAYLOADS", + payload: { + sessionId: historySessionId || asString(data.sessionId) || "", + events: rawSdkEventPayloads, + }, + }); + } + const normalizedMessages = sourceMessages + .map((msg) => normalizeMessage(msg, null)) + .filter((msg): msg is Message => !!msg) + .filter((msg) => isRenderableHistoryMessage(msg)); const messages = coalesceAdjacentAssistantHistoryMessages(normalizedMessages); const hydratedMessages = hydrateLegacyInteractiveUserMessages(messages); @@ -11672,13 +11697,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () ); } canonicalMessages = stabilizedHydratedMessages; - logFullPayloadSnapshot("PRE-RENDER", "chatHistory", { - sessionId: chatHistorySessionId || historySessionId, - messages: canonicalMessages, - processingSessionIds: effectiveProcessingSessionIds, - }); - - if (chatHistorySessionId) { + if (chatHistorySessionId) { dispatch({ type: "SET_SESSION_ID", payload: chatHistorySessionId }); } @@ -12157,30 +12176,9 @@ export function createMessageHandler(dispatch: Dispatch, getState: () streamEventType === "start" || streamEventType === "streamStart"; const canStartVisibleAssistantTurn = streamEventCanStartVisibleAssistantTurn(payload); - streamDebug("Stream: inbound event received", { - ...summarizeStreamEventForLog(payload), - eventSessionId: eventSessionId || null, - activeSessionId: activeSessionId || null, - isProcessing: stateBeforeStreamEvent.isProcessing, - hasConfirmedProcessingSession, - hasStreamingSnapshot: !!stateBeforeStreamEvent.streaming, - isExplicitStreamStart, - canStartVisibleAssistantTurn, - }); - - // Only log important events, not every text chunk - const shouldLogStreamEvent = !streamEventType.includes("message.part") || - streamEventType === "message.completed" || - streamEventType === "session.completed"; - - if (shouldLogStreamEvent) { - logFullPayloadSnapshot("SOURCE", "streamEvent", { - streamEventType, - eventSessionId: eventSessionId || null, - activeSessionId: activeSessionId || null, - payload, - }); - } + const shouldLogStreamEvent = !streamEventType.includes("message.part") || + streamEventType === "message.completed" || + streamEventType === "session.completed"; if ( !stateBeforeStreamEvent.isProcessing && @@ -12189,15 +12187,8 @@ export function createMessageHandler(dispatch: Dispatch, getState: () !isExplicitStreamStart && !canStartVisibleAssistantTurn ) { - streamDebug("Stream: event dropped before handling", { - ...summarizeStreamEventForLog(payload), - reason: "not-processing-and-no-active-stream", - eventSessionId: eventSessionId || null, - activeSessionId: activeSessionId || null, - canStartVisibleAssistantTurn, - }); - break; - } + break; + } if (terminalErrorReached && (getState().isProcessing || hasConfirmedProcessingSession)) { terminalErrorReached = false; } @@ -12220,18 +12211,11 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } } - streamDebug("Stream: event received from extension", { - type: streamEventType, - hasProperties: !!asRecord(payload.properties), - hasPart: !!asRecord(asRecord(payload.properties)?.part), - structuredKind: - asString(asRecord(payload.structured)?.kind) || "unknown", - }); - if ( - eventSessionId && - activeSessionId && - eventSessionId !== activeSessionId - ) { + if ( + eventSessionId && + activeSessionId && + eventSessionId !== activeSessionId + ) { let scopedState: AppState = { ...stateBeforeStreamEvent, currentSessionId: eventSessionId, @@ -12241,14 +12225,15 @@ export function createMessageHandler(dispatch: Dispatch, getState: () null, }; const scopedGetState = () => scopedState; - const scopedDispatch: Dispatch = (action) => { - switch (action.type) { - case "SET_STREAMING": - case "UPDATE_STREAMING_CONTENT": - case "UPDATE_STREAMING_REASONING": - case "SET_IN_REASONING_PART": - case "SET_ASSISTANT_TURN_PENDING": - case "ADD_STREAMING_STEP": + const scopedDispatch: Dispatch = (action) => { + switch (action.type) { + case "SET_STREAMING": + case "APPEND_SDK_EVENT_PAYLOAD": + case "UPDATE_STREAMING_CONTENT": + case "UPDATE_STREAMING_REASONING": + case "SET_IN_REASONING_PART": + case "SET_ASSISTANT_TURN_PENDING": + case "ADD_STREAMING_STEP": case "UPDATE_STREAMING_STEP": case "ADD_STREAMING_EDIT": case "FINISH_STREAMING": @@ -12274,21 +12259,33 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }); break; } - handleStreamEvent(dispatch, getState, payload, terminalErrorReached); - const streamingAfter = getState().streaming; - if (streamingAfter) { - latestStreamingSnapshot = streamingAfter; - } - - - if ( - streamEventType === "message.updated" || + handleStreamEvent(dispatch, getState, payload, terminalErrorReached); + const streamingAfter = getState().streaming; + if (streamingAfter) { + latestStreamingSnapshot = streamingAfter; + } + + const persistSessionId = + eventSessionId || + activeSessionId || + getState().currentSessionId || + null; + if (persistSessionId) { + vscode.postMessage({ + type: "persistRawSdkEventPayload", + sessionId: persistSessionId, + event: payload, + }); + } + + + if ( + streamEventType === "message.updated" || streamEventType === "message.completed" || streamEventType === "session.completed" ) { const stateNow = getState(); - logRenderSnapshot(`streamEvent:${streamEventType}`, stateNow.messages); - } + } break; } case "streamEventEnrich": { @@ -12678,17 +12675,28 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const currentMessages = currentState.messages || []; const updatedMessages = [...currentMessages]; const currentStreaming = currentState.streaming; - const hasStaleEmptyStreamingPlaceholder = - !!currentStreaming && - currentStreaming.isActive === true && - !hasVisibleStreamingSnapshot(currentStreaming) && - !currentStreaming.messageId; - if (hasStaleEmptyStreamingPlaceholder) { - // If the previous turn left behind an active but empty streaming - // shell, clear it now so the next send starts from a clean state - // instead of inheriting a perpetual loading bubble. - dispatch({ type: "SET_STREAMING", payload: null }); - } + const hasStaleEmptyStreamingPlaceholder = + !!currentStreaming && + currentStreaming.isActive === true && + !hasVisibleStreamingSnapshot(currentStreaming) && + !currentStreaming.messageId; + if (hasStaleEmptyStreamingPlaceholder) { + // Preserve the shell until the next live stream event replaces it. + // Clearing here can hide the assistant card before realtime payloads + // have a chance to populate it. + logger.info("[TRACE][HANDLER][PRESERVE_EMPTY_STREAMING_PLACEHOLDER]", { + currentSessionId: currentState.currentSessionId, + streamingMessageId: currentStreaming.messageId ?? null, + streamingActive: currentStreaming.isActive, + hasVisibleStreamingSnapshot: hasVisibleStreamingSnapshot(currentStreaming), + }); + console.info("[TRACE][HANDLER][PRESERVE_EMPTY_STREAMING_PLACEHOLDER]", { + currentSessionId: currentState.currentSessionId, + streamingMessageId: currentStreaming.messageId ?? null, + streamingActive: currentStreaming.isActive, + hasVisibleStreamingSnapshot: hasVisibleStreamingSnapshot(currentStreaming), + }); + } const persistedAssistantMessageIds = new Set( currentMessages .filter((candidate) => isAssistantHistoryMessage(candidate)) diff --git a/webview/shared/src/chat/lib/rawResponse.ts b/webview/shared/src/chat/lib/rawResponse.ts new file mode 100644 index 0000000..2ebdc58 --- /dev/null +++ b/webview/shared/src/chat/lib/rawResponse.ts @@ -0,0 +1,510 @@ +import type { + InteractiveChoice, + InteractiveEvent, + OpenCodeRawResponse, + OpenCodeRawResponsePart, +} from "./types"; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +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; +} + +function normalizeRawResponse(rawResponse: OpenCodeRawResponse | string): Record | null { + if (typeof rawResponse !== "string") { + return asRecord(rawResponse); + } + + const trimmed = rawResponse.trim(); + if (!trimmed) { + return null; + } + + try { + const parsed = JSON.parse(trimmed) as unknown; + return asRecord(parsed); + } catch { + return null; + } +} + +function recordAtPath( + root: Record | null, + path: string[], +): Record | null { + let current: unknown = root; + for (const segment of path) { + const next = asRecord(current); + if (!next) { + return null; + } + current = next[segment]; + } + return asRecord(current); +} + +function firstRecordAtPaths( + root: Record | null, + paths: string[][], +): Record | null { + for (const path of paths) { + const found = recordAtPath(root, path); + if (found) { + return found; + } + } + return null; +} + +function finalTextBeforeStepFinish(parts?: Array | null | undefined>): string { + if (!Array.isArray(parts) || parts.length === 0) { + return ""; + } + + for (let index = parts.length - 1; index >= 0; index -= 1) { + const partRec = asRecord(parts[index]); + if (!partRec) { + continue; + } + + const partType = firstNonEmptyString( + typeof partRec.type === "string" ? partRec.type : undefined, + typeof partRec.partType === "string" ? partRec.partType : undefined, + )?.toLowerCase(); + + if (partType === "step-finish") { + continue; + } + + const text = firstNonEmptyString( + typeof partRec.message === "string" ? partRec.message : undefined, + typeof partRec.text === "string" ? partRec.text : undefined, + typeof partRec.content === "string" ? partRec.content : undefined, + ); + if (text) { + return text; + } + } + + return ""; +} + +function normalizeInteractiveChoices(raw: unknown): InteractiveChoice[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .map((item, index) => { + if (typeof item === "string") { + const label = item.trim(); + return label ? { id: `choice-${index}`, label, value: label } : null; + } + const rec = asRecord(item); + if (!rec) { + return null; + } + const label = firstNonEmptyString(rec.label, rec.value, rec.title, rec.text); + if (!label) { + return null; + } + return { + id: firstNonEmptyString(rec.id) || `choice-${index}`, + label, + value: firstNonEmptyString(rec.value) || undefined, + description: firstNonEmptyString(rec.description, rec.detail) || undefined, + }; + }) + .filter((item): item is InteractiveChoice => !!item); +} + +function interactiveEventFromQuestionRecord( + record: Record | null, + fallbackId: string, +): InteractiveEvent | null { + if (!record) { + return null; + } + const prompt = firstNonEmptyString( + record.displayPrompt, + record.question, + record.message, + record.content, + record.title, + record.prompt, + ); + if (!prompt) { + return null; + } + + const type = firstNonEmptyString(record.type)?.toLowerCase() || "question"; + if (type === "confirm") { + return { + type: "confirm", + id: firstNonEmptyString(record.id) || fallbackId, + title: firstNonEmptyString(record.title) || undefined, + question: prompt, + }; + } + + if (type === "quick_actions" || type === "quick-actions") { + const actions = normalizeInteractiveChoices(record.actions ?? record.options ?? record.choices); + return actions.length > 0 + ? { + type: "quick_actions", + id: firstNonEmptyString(record.id) || fallbackId, + title: prompt, + actions, + } + : null; + } + + const options = normalizeInteractiveChoices(record.options ?? record.choices ?? record.actions); + const allowCustomInput = record.allowCustomInput === true; + if (options.length < 2 && !allowCustomInput) { + return null; + } + + return { + type: "question", + id: firstNonEmptyString(record.id) || fallbackId, + title: firstNonEmptyString(record.title) || undefined, + question: prompt, + options, + multiSelect: record.multiSelect === true, + allowCustomInput, + }; +} + +function interactiveEventsFromStructuredQuestionRecord( + record: Record | null | undefined, + fallbackId: string, +): InteractiveEvent[] { + if (!record) { + return []; + } + + const questions = Array.isArray(record.questions) + ? record.questions + : Array.isArray(record.items) + ? record.items + : Array.isArray(record.prompts) + ? record.prompts + : Array.isArray(record.events) + ? record.events + : null; + + const entries = Array.isArray(questions) && questions.length > 0 + ? questions + : [record.question ?? record]; + + return entries + .map((entry, index) => interactiveEventFromQuestionRecord(asRecord(entry), `${fallbackId}-${index}`)) + .filter((entry): entry is InteractiveEvent => !!entry); +} + +function interactiveEventsFromToolParts( + parts: Array | null | undefined> | undefined, + fallbackId: string, +): InteractiveEvent[] { + if (!Array.isArray(parts) || parts.length === 0) { + return []; + } + + for (const part of parts) { + const partRec = asRecord(part); + if (!partRec) { + continue; + } + const toolName = firstNonEmptyString(partRec.tool, partRec.name)?.toLowerCase(); + if (toolName !== "structuredoutput" && toolName !== "structured_output") { + continue; + } + + const state = asRecord(partRec.state); + const input = asRecord(state?.input) || asRecord(partRec.input) || asRecord(partRec.arguments); + if (!input) { + continue; + } + + const questionRecord = + asRecord(input.question) || + asRecord(input.questions?.[0]) || + asRecord(input.prompt) || + asRecord(input.message) || + asRecord(input); + + const responseType = firstNonEmptyString( + input.responseType, + input.type, + partRec.responseType, + )?.toLowerCase(); + + const question = questionRecord ?? (responseType === "question" ? input : null); + if (!question) { + continue; + } + + const events = interactiveEventsFromStructuredQuestionRecord( + { + ...input, + ...question, + type: firstNonEmptyString(question.type, input.type, responseType) || "question", + }, + `${fallbackId}-${firstNonEmptyString(partRec.id, partRec.callID, partRec.callId) || "tool"}`, + ); + if (events.length > 0) { + return events; + } + } + + return []; +} + +function rawSdkEventPartRecord( + payload: unknown, +): { + event: Record; + properties: Record | null; + part: Record | null; +} | null { + const event = asRecord(payload); + if (!event) { + return null; + } + const properties = asRecord(event.properties); + const part = asRecord(properties?.part) ?? asRecord(event.part); + return { event, properties, part }; +} + +function rawSdkEventPartRecords( + rawSdkEventPayloads?: unknown[], +): Array> { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return []; + } + + const parts: Array> = []; + for (const payload of rawSdkEventPayloads) { + const rec = rawSdkEventPartRecord(payload); + if (!rec?.part) { + continue; + } + parts.push(rec.part); + } + return parts; +} + +/** + * Extract the final assistant-facing response from the raw SDK payload. + * + * Priority: + * 1. `rawResponse.info.structured.message` or the equivalent nested sync payload + * 2. Last text-bearing part before the terminal `step-finish` + */ +export function getFinalAssistantResponseText( + rawResponse?: OpenCodeRawResponse | string, +): string { + if (!rawResponse) { + return ""; + } + + const normalized = normalizeRawResponse(rawResponse); + if (!normalized) { + return ""; + } + + const rawInfoRec = firstRecordAtPaths(normalized, [ + ["info"], + ["payload", "syncEvent", "data", "info"], + ["payload", "data", "info"], + ["data", "info"], + ]); + const structured = asRecord(rawInfoRec?.structured); + const structuredMessage = firstNonEmptyString( + typeof structured?.message === "string" ? structured.message : undefined, + typeof structured?.content === "string" ? structured.content : undefined, + ); + if (structuredMessage) { + return structuredMessage; + } + + const partsBody = finalTextBeforeStepFinish( + Array.isArray(normalized.parts) ? normalized.parts : [], + ); + if (partsBody) { + return partsBody; + } + return ""; +} + +export function getFinalAssistantResponseTextFromRawSdkEventPayloads( + rawSdkEventPayloads?: unknown[], +): string { + const parts = rawSdkEventPartRecords(rawSdkEventPayloads); + if (parts.length === 0) { + return ""; + } + + for (let index = parts.length - 1; index >= 0; index -= 1) { + const partRec = asRecord(parts[index]); + if (!partRec) { + continue; + } + + const partType = firstNonEmptyString( + typeof partRec.type === "string" ? partRec.type : undefined, + typeof partRec.partType === "string" ? partRec.partType : undefined, + )?.toLowerCase(); + if (partType === "step-finish") { + continue; + } + + const text = firstNonEmptyString( + typeof partRec.message === "string" ? partRec.message : undefined, + typeof partRec.text === "string" ? partRec.text : undefined, + typeof partRec.content === "string" ? partRec.content : undefined, + ); + if (text) { + return text; + } + } + + return ""; +} + +export function getInteractiveEventsFromRawResponse( + rawResponse?: OpenCodeRawResponse | string, +): InteractiveEvent[] { + if (!rawResponse) { + return []; + } + + const normalized = normalizeRawResponse(rawResponse); + if (!normalized) { + return []; + } + + const rawInfoRec = firstRecordAtPaths(normalized, [ + ["info"], + ["payload", "syncEvent", "data", "info"], + ["payload", "data", "info"], + ["data", "info"], + ]); + + const rawPartEvents = interactiveEventsFromToolParts( + Array.isArray(normalized.parts) ? normalized.parts : undefined, + firstNonEmptyString(rawInfoRec?.id, rawInfoRec?.parentID, normalized.id) || `question-${Date.now()}`, + ); + if (rawPartEvents.length > 0) { + return rawPartEvents; + } + + const structured = + firstRecordAtPaths(normalized, [ + ["info", "structured"], + ["info", "structuredOutput"], + ["info", "structured_output"], + ["structured"], + ["structuredOutput"], + ["structured_output"], + ]) || + firstRecordAtPaths(rawInfoRec, [["structured"], ["structuredOutput"], ["structured_output"]]); + + const responseType = firstNonEmptyString( + rawInfoRec?.responseType, + structured?.responseType, + normalized.responseType, + )?.toLowerCase(); + + const questionCandidate = + firstRecordAtPaths(structured, [["question"]]) || + firstRecordAtPaths(rawInfoRec, [["question"]]) || + firstRecordAtPaths(normalized, [["question"]]); + + const hasQuestionLikePayload = + responseType === "question" || + !!questionCandidate || + Array.isArray((structured as Record | null)?.interactiveEvents); + if (!hasQuestionLikePayload) { + return []; + } + + const structuredEvents = interactiveEventsFromStructuredQuestionRecord( + structured, + firstNonEmptyString(rawInfoRec?.id, rawInfoRec?.parentID, normalized.id) || `question-${Date.now()}`, + ); + if (structuredEvents.length > 0) { + return structuredEvents; + } + + const fallbackEvent = interactiveEventFromQuestionRecord( + asRecord(questionCandidate), + firstNonEmptyString(rawInfoRec?.id, rawInfoRec?.parentID, normalized.id) || `question-${Date.now()}`, + ); + return fallbackEvent ? [fallbackEvent] : []; +} + +export function getInteractiveEventsFromRawSdkEventPayloads( + rawSdkEventPayloads?: unknown[], +): InteractiveEvent[] { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return []; + } + + for (let index = rawSdkEventPayloads.length - 1; index >= 0; index -= 1) { + const rec = rawSdkEventPartRecord(rawSdkEventPayloads[index]); + if (!rec?.part) { + continue; + } + + const partRec = rec.part; + const toolName = firstNonEmptyString(partRec.tool, partRec.name)?.toLowerCase(); + if (toolName === "structuredoutput" || toolName === "structured_output") { + const state = asRecord(partRec.state); + const input = asRecord(state?.input) || asRecord(partRec.input) || asRecord(partRec.arguments); + if (input) { + const questionRecord = + asRecord(input.question) || + asRecord(input.questions?.[0]) || + asRecord(input.prompt) || + asRecord(input.message) || + asRecord(input); + const responseType = firstNonEmptyString( + input.responseType, + input.type, + partRec.responseType, + )?.toLowerCase(); + const question = questionRecord ?? (responseType === "question" ? input : null); + if (question) { + const events = interactiveEventsFromStructuredQuestionRecord( + { + ...input, + ...question, + type: firstNonEmptyString(question.type, input.type, responseType) || "question", + }, + `${firstNonEmptyString(partRec.id, partRec.callID, partRec.callId, rec.event.id) || "question"}`, + ); + if (events.length > 0) { + return events; + } + } + } + } + } + + return []; +} diff --git a/webview/shared/src/chat/lib/sessionProcessing.ts b/webview/shared/src/chat/lib/sessionProcessing.ts index ba1e563..cd29330 100644 --- a/webview/shared/src/chat/lib/sessionProcessing.ts +++ b/webview/shared/src/chat/lib/sessionProcessing.ts @@ -1,3 +1,6 @@ +import { getFinalAssistantResponseText } from "./rawResponse"; +import type { Message } from "./types"; + export function isProcessingInCurrentSession( isProcessing: boolean, currentSessionId: string | null, @@ -20,3 +23,75 @@ export function isProcessingInCurrentSession( } return processingSessionIds.includes(currentSessionId); } + +export function hasCompletedAssistantReplyForLatestTurn( + messages: Message[], +): boolean { + if (!Array.isArray(messages) || messages.length === 0) { + return false; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + const role = String(message?.role ?? message?.info?.role ?? "").toLowerCase(); + + if (role === "assistant") { + // Prefer the raw assistant tape when deciding whether the latest turn is + // finished. This keeps the UI aligned with the source-of-truth payload + // instead of relying on a stale processing flag. + const rawAssistantText = getFinalAssistantResponseText(message.rawResponse); + const structuredText = + typeof message.structuredOutput?.message === "string" + ? message.structuredOutput.message.trim() + : ""; + const content = + typeof message.content === "string" ? message.content.trim() : ""; + return Boolean(rawAssistantText || structuredText || content); + } + + if (role === "user") { + return false; + } + } + + return false; +} + +export function hasActiveAssistantTurnContext( + messages: Message[], + isStreamingActive: boolean, + assistantTurnPending: boolean, +): boolean { + if (isStreamingActive || assistantTurnPending) { + return true; + } + return Array.isArray(messages) && messages.length > 0; +} + +export function isAssistantRespondingInCurrentSession( + isProcessing: boolean, + currentSessionId: string | null, + processingSessionIds: string[], + isStreamingActive: boolean, + assistantTurnPending: boolean, + hasAssistantFinishSignal?: boolean, + hasTerminalStepSignal?: boolean, + hasCompletedAssistantReplyForLatestTurn?: boolean, + hasActiveTurnContext?: boolean, +): boolean { + // Keep the composer stop button and the transcript loading affordance tied to + // the same "AI is still active" signal. The transport can briefly lose the + // processing flag while stream activity is still flowing, so we treat either + // live streaming or a pending assistant turn as still responding. + if (!hasActiveTurnContext) { + return false; + } + + if (hasAssistantFinishSignal || hasTerminalStepSignal || hasCompletedAssistantReplyForLatestTurn) { + return false; + } + + const isProcessingOrStreaming = isProcessingInCurrentSession(isProcessing, currentSessionId, processingSessionIds) || isStreamingActive; + + return isProcessingOrStreaming || assistantTurnPending; +} diff --git a/webview/shared/src/chat/lib/store.test.ts b/webview/shared/src/chat/lib/store.test.ts index 16c24fb..c619f78 100644 --- a/webview/shared/src/chat/lib/store.test.ts +++ b/webview/shared/src/chat/lib/store.test.ts @@ -178,6 +178,38 @@ describe('error message reducer state', () => { }); }); +describe('raw event capture', () => { + it('stores the exact incoming SDK payload by session id', () => { + const rawEvent = { + type: 'message.part.updated', + sessionId: 'ses_123', + payload: { + nested: true, + text: 'raw text', + }, + }; + + const nextState = appReducer( + { + ...initialState, + currentSessionId: 'ses_123', + }, + { + type: 'APPEND_RAW_SDK_EVENT_PAYLOAD', + payload: { + sessionId: 'ses_123', + event: rawEvent, + }, + }, + ); + + assert.deepStrictEqual( + nextState.rawSdkEventPayloadsBySessionId?.['ses_123'], + [rawEvent], + ); + }); +}); + describe('hasSystemMessagePatternInText', () => { it('should detect square-bracketed system messages in plain text', () => { const text = '[analyze-mode]'; @@ -405,6 +437,35 @@ describe("appReducer render-stability guards", () => { assert.strictEqual(next.interactiveEvents.length, 0); }); + it("preserves rawSdkEventPayloads when caching session streaming state", () => { + const payloads = [ + { type: "message.start", id: "evt-1" }, + { type: "message.delta", text: "hello" }, + ]; + const next = appReducer(initialState, { + type: "SET_SESSION_STREAMING", + payload: { + sessionId: "session-a", + streaming: { + isActive: true, + content: "hello", + reasoning: "", + reasoningEvents: [], + progressEvents: [], + steps: [], + edits: [], + interactiveEvents: [], + rawSdkEventPayloads: payloads, + } as any, + }, + }); + + assert.deepStrictEqual( + next.streamingBySessionId?.["session-a"]?.rawSdkEventPayloads, + payloads, + ); + }); + it("switches visible messages immediately on SET_SESSION_ID", () => { const stateWithSessionCache = { ...initialState, @@ -516,7 +577,7 @@ describe('extractMessageTextForCanonical', () => { { type: 'text', text: 'world' }, ], }; - assert.strictEqual(extractMessageTextForCanonical(message), 'hello\nworld'); + assert.strictEqual(extractMessageTextForCanonical(message), 'hello world'); }); it('should handle missing content and parts', () => { diff --git a/webview/shared/src/chat/lib/store.ts b/webview/shared/src/chat/lib/store.ts index fd13bba..0de4713 100644 --- a/webview/shared/src/chat/lib/store.ts +++ b/webview/shared/src/chat/lib/store.ts @@ -1,5 +1,6 @@ import React, { createContext, useContext, useMemo, useReducer } from 'react'; import logger from './logger'; +import { getInteractiveEventsFromRawResponse } from "./rawResponse"; import type { Agent, @@ -45,6 +46,8 @@ export const initialState: AppState = { currentSessionId: null, messages: [], messagesBySessionId: {}, + rawMessagesBySessionId: {}, + rawSdkEventPayloadsBySessionId: {}, promptQueue: [], queueBySessionId: {}, isExecutingQueue: false, @@ -148,6 +151,12 @@ export type AppAction = | { type: "SET_SELECTED_AGENT"; payload: string } | { type: "SET_AGENTS_LIST"; payload: Agent[] } | { type: "SET_MESSAGES"; payload: Message[] } + | { type: "SET_RAW_MESSAGES"; payload: { sessionId: string; messages: unknown[] } } + | { + type: "SET_RAW_SDK_EVENT_PAYLOADS"; + payload: { sessionId: string; events: unknown[] }; + } + | { type: "APPEND_RAW_SDK_EVENT_PAYLOAD"; payload: { sessionId?: string | null; event: unknown } } | { type: "CACHE_SESSION_MESSAGES"; payload: { sessionId: string; messages: Message[] }; @@ -431,6 +440,7 @@ function buildStreamingMessageLocal(streaming: StreamingState): Message { edits: streaming.edits.map((file) => ({ file })), interactiveEvents: streaming.interactiveEvents, structuredOutput: streaming.structuredOutput, + rawSdkEventPayloads: streaming.rawSdkEventPayloads, info: { id: streaming.messageId ?? undefined, agent: streaming.agent, @@ -625,7 +635,8 @@ function hasVisibleStreamingSnapshotLocal( (Array.isArray(streaming.progressEvents) && streaming.progressEvents.length > 0) || (Array.isArray(streaming.steps) && streaming.steps.length > 0) || (Array.isArray(streaming.edits) && streaming.edits.length > 0) || - (Array.isArray(streaming.interactiveEvents) && streaming.interactiveEvents.length > 0) + (Array.isArray(streaming.interactiveEvents) && streaming.interactiveEvents.length > 0) || + (Array.isArray(streaming.rawSdkEventPayloads) && streaming.rawSdkEventPayloads.length > 0) ); } @@ -644,6 +655,7 @@ function cacheVisibleStreamingMessageForSession( ...(current ?? {}), [sessionId]: canonicalizeMessagesForRender( mergeStreamingSnapshotIntoMessagesLocal(existingMessages, streaming), + { preserveEvtAssistantMessages: true }, ), }; } @@ -651,13 +663,21 @@ function cacheVisibleStreamingMessageForSession( function getStructuredRecordLocal(message: Message): Record | null { const rec = asRecordLocal(message); const info = asRecordLocal(message.info); + const rawResponse = parseRawResponseRecordForCanonical(rec?.rawResponse); + const rawResponseInfo = asRecordLocal(rawResponse?.info); return ( asRecordLocal(rec?.structuredOutput) || asRecordLocal(rec?.structured_output) || asRecordLocal(rec?.structured) || asRecordLocal(info?.structuredOutput) || asRecordLocal(info?.structured_output) || - asRecordLocal(info?.structured) + asRecordLocal(info?.structured) || + asRecordLocal(rawResponseInfo?.structured) || + asRecordLocal(rawResponseInfo?.structuredOutput) || + asRecordLocal(rawResponseInfo?.structured_output) || + asRecordLocal(rawResponse?.structured) || + asRecordLocal(rawResponse?.structuredOutput) || + asRecordLocal(rawResponse?.structured_output) ); } @@ -751,6 +771,12 @@ function interactiveEventsFromLatestQuestionMessageLocal( if (role !== "assistant") { return []; } + const rawEvents = getInteractiveEventsFromRawResponse( + (message as unknown as Record).rawResponse, + ); + if (rawEvents.length > 0) { + return rawEvents; + } if (Array.isArray(message.interactiveEvents) && message.interactiveEvents.length > 0) { // Filter out false positive fallback interactive events from rehydrated messages const filteredEvents = message.interactiveEvents.filter((event) => { @@ -1014,10 +1040,31 @@ function contentFromRenderablePartsForCanonical(parts: unknown[]): string { } return asStringLocal(partRec.text, partRec.content, partRec.delta); }) - .join("") + .join(" ") .trim(); } +function parseRawResponseRecordForCanonical( + rawResponse: Message["rawResponse"], +): Record | undefined { + const direct = asRecordLocal(rawResponse); + if (direct) { + return direct; + } + if (typeof rawResponse !== "string") { + return undefined; + } + const trimmed = rawResponse.trim(); + if (!trimmed) { + return undefined; + } + try { + return asRecordLocal(JSON.parse(trimmed)); + } catch { + return undefined; + } +} + function collectReasoningFingerprintsForCanonical(message: Message): Set { const fingerprints = new Set(); const add = (value: unknown): void => { @@ -1114,7 +1161,16 @@ function extractRenderableAssistantTextForCanonical(message: Message): string { } const parts = Array.isArray(rec.parts) ? rec.parts : []; - return contentFromRenderablePartsForCanonical(parts); + const partsContent = contentFromRenderablePartsForCanonical(parts); + if (partsContent) { + return partsContent; + } + + const rawResponseRec = parseRawResponseRecordForCanonical(rec.rawResponse); + const rawResponseParts = Array.isArray(rawResponseRec?.parts) + ? rawResponseRec.parts + : []; + return contentFromRenderablePartsForCanonical(rawResponseParts); } export function isInternalTransportReminderMessage(message: Message): boolean { @@ -1255,16 +1311,30 @@ export function messageRichnessScoreForCanonical(message: Message): number { } export function dedupeMirrorMessagesForCanonical(messages: Message[]): Message[] { - const preserveRawResponse = (preferred: Message, alternate: Message): Message => { + const preserveRawDebugFields = (preferred: Message, alternate: Message): Message => { const preferredRecord = preferred as unknown as Record; - if (Object.prototype.hasOwnProperty.call(preferredRecord, "rawResponse")) { - return preferred; - } const alternateRecord = alternate as unknown as Record; - if (!Object.prototype.hasOwnProperty.call(alternateRecord, "rawResponse")) { + const preferredRawResponse = preferredRecord.rawResponse; + const alternateRawResponse = alternateRecord.rawResponse; + const preferredRawSdkEventPayloads = Array.isArray(preferredRecord.rawSdkEventPayloads) && + preferredRecord.rawSdkEventPayloads.length > 0 + ? preferredRecord.rawSdkEventPayloads + : undefined; + const alternateRawSdkEventPayloads = Array.isArray(alternateRecord.rawSdkEventPayloads) && + alternateRecord.rawSdkEventPayloads.length > 0 + ? alternateRecord.rawSdkEventPayloads + : undefined; + if (typeof preferredRawResponse !== "undefined" && preferredRawSdkEventPayloads) { return preferred; } - return { ...preferred, rawResponse: alternate.rawResponse }; + const next: Message = { ...preferred }; + if (typeof preferredRawResponse === "undefined" && typeof alternateRawResponse !== "undefined") { + next.rawResponse = alternateRawResponse; + } + if (!preferredRawSdkEventPayloads && alternateRawSdkEventPayloads) { + next.rawSdkEventPayloads = [...alternateRawSdkEventPayloads]; + } + return next; }; const messageMetaCache = new WeakMap< @@ -1300,7 +1370,7 @@ export function dedupeMirrorMessagesForCanonical(messages: Message[]): Message[] ? incoming : existing; const alternate = preferred === incoming ? existing : incoming; - return preserveRawResponse(preferred, alternate); + return preserveRawDebugFields(preferred, alternate); }; const idToIndex = new Map(); @@ -1474,20 +1544,8 @@ function appendUniqueEntries( } export function coalesceAssistantRunForCanonical(run: Message[]): Message { - // Lifecycle events (streaming start/finish) carry evt_-prefixed message IDs. - // When the same assistant turn produces both an evt_ lifecycle snapshot and a - // msg_ streaming snapshot, the evt_ entry may carry stale streaming text that - // was captured at lifecycle-start. Use the last non-evt message as the merge - // base so the canonical ID and renderable text come from the authoritative - // msg_ snapshot — the evt_ metadata (steps, progress, structured output) is - // still folded in during the merge loop below. - const preferredIdx = run.length > 1 - ? run.findLastIndex( - (m: Message) => !(typeof m?.info?.id === "string" && m.info.id.startsWith("evt_")), - ) - : -1; const base = { - ...(preferredIdx >= 0 ? run[preferredIdx] : run[run.length - 1] || run[0]), + ...(run[run.length - 1] || run[0]), } as Message; const mergedParts: unknown[] = []; const seenPartKeys = new Set(); @@ -1511,6 +1569,11 @@ export function coalesceAssistantRunForCanonical(run: Message[]): Message { let latestSubagentsWithoutMessageId: Message["subagents"] | undefined; let latestError = asStringLocal((base as unknown as Record).error); let latestRawResponse = (base as unknown as Record).rawResponse; + let latestRawSdkEventPayloads: unknown[] | undefined = Array.isArray( + (base as unknown as Record).rawSdkEventPayloads, + ) + ? [...((base as unknown as Record).rawSdkEventPayloads as unknown[])] + : undefined; let latestStructuredOutput = asRecordLocal( (base as unknown as Record).structuredOutput, ) ?? getStructuredRecordLocal(base); @@ -1532,24 +1595,17 @@ export function coalesceAssistantRunForCanonical(run: Message[]): Message { for (const message of run) { const messageId = getMessageIdForCanonical(message); if (messageId) { - const incomingIsEvt = typeof messageId === "string" && messageId.startsWith("evt_"); - const currentIsNonEvt = typeof canonicalId === "string" && !canonicalId.startsWith("evt_"); - if (!incomingIsEvt || !currentIsNonEvt) { - canonicalId = messageId; - } + canonicalId = messageId; } const text = extractRenderableAssistantTextForCanonical(message); if (text) { - const incomingIsEvt = typeof messageId === "string" && messageId.startsWith("evt_"); - if (!incomingIsEvt || !latestText) { - latestText = text; - latestTextPart = Array.isArray(message.parts) - ? message.parts.find((part) => { - const partRec = asRecordLocal(part); - return !!partRec && isRenderableAssistantTextPartForCanonical(partRec); - }) - : undefined; - } + latestText = text; + latestTextPart = Array.isArray(message.parts) + ? message.parts.find((part) => { + const partRec = asRecordLocal(part); + return !!partRec && isRenderableAssistantTextPartForCanonical(partRec); + }) + : undefined; } if (Array.isArray(message.interactiveEvents) && message.interactiveEvents.length > 0) { latestInteractiveEvents = message.interactiveEvents; @@ -1571,6 +1627,12 @@ export function coalesceAssistantRunForCanonical(run: Message[]): Message { } else if (typeof message.rawResponse !== "undefined") { latestRawResponse = message.rawResponse; } + if (Array.isArray((message as unknown as Record).rawSdkEventPayloads)) { + const rawSdkEventPayloads = (message as unknown as Record).rawSdkEventPayloads as unknown[]; + if (rawSdkEventPayloads.length > 0) { + latestRawSdkEventPayloads = [...rawSdkEventPayloads]; + } + } const errorText = asStringLocal( (message as unknown as Record).error, ); @@ -1706,6 +1768,11 @@ export function coalesceAssistantRunForCanonical(run: Message[]): Message { // "Raw Response (Debug)" to disappear after refresh/session reload. base.rawResponse = latestRawResponse; } + if (Array.isArray(latestRawSdkEventPayloads) && latestRawSdkEventPayloads.length > 0) { + (base as unknown as Record).rawSdkEventPayloads = latestRawSdkEventPayloads; + } else { + delete (base as unknown as Record).rawSdkEventPayloads; + } if (canonicalId) { base.id = canonicalId; const info = asRecordLocal(base.info); @@ -1716,10 +1783,14 @@ export function coalesceAssistantRunForCanonical(run: Message[]): Message { return base; } -export function canonicalizeMessagesForRender(messages: Message[]): Message[] { +export function canonicalizeMessagesForRender( + messages: Message[], + options?: { preserveEvtAssistantMessages?: boolean }, +): Message[] { if (!Array.isArray(messages) || messages.length === 0) { return []; } + void options; // Convert internal transport messages (like ) to system role // for consistent deduplication and rendering handling. @@ -1785,19 +1856,7 @@ export function canonicalizeMessagesForRender(messages: Message[]): Message[] { index = cursor; } - const hasNonEvtAssistant = canonical.some( - (m) => - isAssistantMessageForCanonical(m) && - !(typeof m?.info?.id === "string" && m.info.id.startsWith("evt_")), - ); - - return hasNonEvtAssistant - ? canonical.filter( - (m) => - !isAssistantMessageForCanonical(m) || - !(typeof m?.info?.id === "string" && m.info.id.startsWith("evt_")), - ) - : canonical; + return canonical; } const MAX_STREAMING_REASONING_EVENTS = 300; @@ -2228,6 +2287,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { state.messages ?? [], state.streaming, ), + { preserveEvtAssistantMessages: true }, ), } : state.messagesBySessionId; @@ -2324,7 +2384,10 @@ export function appReducer(state: AppState, action: AppAction): AppState { case "SET_AGENTS_LIST": return { ...state, availableAgents: action.payload }; case "SET_MESSAGES": { - const canonicalMessages = canonicalizeMessagesForRender(action.payload); + const canonicalMessages = canonicalizeMessagesForRender(action.payload, { + preserveEvtAssistantMessages: + !!state.streaming?.isActive || state.assistantTurnPending, + }); const resolvedDividerIndex = resolveCompactionDividerIndex(canonicalMessages, { compactionDividerIndex: state.compactionDividerIndex, compactionDividerBeforeMessageId: state.compactionDividerBeforeMessageId, @@ -2381,6 +2444,42 @@ export function appReducer(state: AppState, action: AppAction): AppState { resolvedAnchors.compactionDividerAfterMessageId, }; } + case "SET_RAW_MESSAGES": { + return { + ...state, + rawMessagesBySessionId: { + ...(state.rawMessagesBySessionId ?? {}), + [action.payload.sessionId]: Array.isArray(action.payload.messages) + ? [...action.payload.messages] + : [], + }, + }; + } + case "SET_RAW_SDK_EVENT_PAYLOADS": { + return { + ...state, + rawSdkEventPayloadsBySessionId: { + ...(state.rawSdkEventPayloadsBySessionId ?? {}), + [action.payload.sessionId]: Array.isArray(action.payload.events) + ? [...action.payload.events] + : [], + }, + }; + } + case "APPEND_RAW_SDK_EVENT_PAYLOAD": { + const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; + if (!sessionId) { + return state; + } + const existing = state.rawSdkEventPayloadsBySessionId?.[sessionId] ?? []; + return { + ...state, + rawSdkEventPayloadsBySessionId: { + ...(state.rawSdkEventPayloadsBySessionId ?? {}), + [sessionId]: [...existing, action.payload.event], + }, + }; + } case "CACHE_SESSION_MESSAGES": return { ...state, @@ -2654,6 +2753,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { hasTerminalStepSignal: action.payload.streaming.hasTerminalStepSignal ?? false, interactiveEvents: action.payload.streaming.interactiveEvents ?? [], + rawSdkEventPayloads: action.payload.streaming.rawSdkEventPayloads ?? [], } : null; const streamingBySessionId = cacheStreamingForSession( @@ -3377,6 +3477,24 @@ export function appReducer(state: AppState, action: AppAction): AppState { interactiveEvents: deduplicatedEvents, } : state.streaming; + logger.info("[TRACE][STORE][SET_INTERACTIVE_EVENTS]", { + currentSessionId: state.currentSessionId, + incomingCount: Array.isArray(action.payload) ? action.payload.length : 0, + filteredCount: filteredEvents.length, + deduplicatedCount: deduplicatedEvents.length, + dismissedCount: state.dismissedInteractiveEventKeys.size, + hasStreaming: !!state.streaming, + streamingInteractiveCount: state.streaming?.interactiveEvents?.length ?? 0, + }); + console.info("[TRACE][STORE][SET_INTERACTIVE_EVENTS]", { + currentSessionId: state.currentSessionId, + incomingCount: Array.isArray(action.payload) ? action.payload.length : 0, + filteredCount: filteredEvents.length, + deduplicatedCount: deduplicatedEvents.length, + dismissedCount: state.dismissedInteractiveEventKeys.size, + hasStreaming: !!state.streaming, + streamingInteractiveCount: state.streaming?.interactiveEvents?.length ?? 0, + }); return { ...state, interactiveEvents: deduplicatedEvents, diff --git a/webview/shared/src/chat/lib/toastEvents.ts b/webview/shared/src/chat/lib/toastEvents.ts new file mode 100644 index 0000000..3befb07 --- /dev/null +++ b/webview/shared/src/chat/lib/toastEvents.ts @@ -0,0 +1,103 @@ +type RawRecord = Record; + +export type ToastVariant = "info" | "success" | "warning" | "error"; + +export interface CentralizedToastNotification { + key: string; + id?: string; + type: string; + title: string; + message: string; + variant: ToastVariant; + durationMs: number; + sessionId?: string; +} + +interface RawToastProperties { + title?: unknown; + message?: unknown; + variant?: unknown; + duration?: unknown; +} + +function asRecord(value: unknown): RawRecord | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as RawRecord) + : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeVariant(value: unknown): ToastVariant { + const variant = asString(value)?.trim().toLowerCase(); + if (variant === "success" || variant === "warning" || variant === "error") { + return variant; + } + return "info"; +} + +function buildToastKey(payload: RawRecord, index: number): string { + const id = + asString(payload.id) || + asString(payload.eventID) || + asString(payload.eventId) || + asString(payload.messageID) || + asString(payload.messageId); + + if (id) { + return id; + } + + const properties = asRecord(payload.properties) as RawToastProperties | null; + const title = asString(properties?.title) || ""; + const message = asString(properties?.message) || ""; + + return [ + "toast", + asString(payload.type) || "unknown", + title, + message, + asString(payload.sessionId) || "", + String(index), + ].join(":"); +} + +export function extractCentralizedToastNotifications( + rawSdkEventPayloads: unknown[] | undefined, +): CentralizedToastNotification[] { + if (!Array.isArray(rawSdkEventPayloads) || rawSdkEventPayloads.length === 0) { + return []; + } + + return rawSdkEventPayloads.flatMap((entry, index) => { + const payload = asRecord(entry); + if (!payload || asString(payload.type) !== "tui.toast.show") { + return []; + } + + const properties = asRecord(payload.properties) as RawToastProperties | null; + const title = asString(properties?.title)?.trim() || "OpenCode"; + const message = asString(properties?.message)?.trim() || ""; + const variant = normalizeVariant(properties?.variant); + const durationMs = asNumber(properties?.duration); + + return [ + { + key: buildToastKey(payload, index), + id: asString(payload.id), + type: "tui.toast.show", + title, + message, + variant, + durationMs: durationMs && durationMs > 0 ? durationMs : 4000, + sessionId: asString(payload.sessionId), + }, + ]; + }); +} diff --git a/webview/shared/src/chat/lib/types.ts b/webview/shared/src/chat/lib/types.ts index 9d1e176..7a1b137 100644 --- a/webview/shared/src/chat/lib/types.ts +++ b/webview/shared/src/chat/lib/types.ts @@ -311,6 +311,78 @@ export interface MessagePart { source?: { path?: string }; } +export interface OpenCodeRawResponsePart { + type?: string; + text?: string; + content?: string; + message?: string; + reason?: string; + snapshot?: string; + id?: string; + sessionID?: string; + messageID?: string; + time?: { + start?: number; + end?: number; + [key: string]: unknown; + }; + state?: Record; + metadata?: Record; + [key: string]: unknown; +} + +export interface OpenCodeRawResponseInfo { + parentID?: string; + role?: string; + mode?: string; + agent?: string; + variant?: string; + path?: { + cwd?: string; + root?: string; + [key: string]: unknown; + }; + cost?: number; + tokens?: { + total?: number; + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + modelID?: string; + providerID?: string; + time?: { + created?: number; + completed?: number; + start?: number; + end?: number; + [key: string]: unknown; + }; + finish?: string; + id?: string; + sessionID?: string; + structured?: Record; + structuredOutput?: Record; + [key: string]: unknown; +} + +export interface OpenCodeRawResponse { + info?: OpenCodeRawResponseInfo; + parts?: OpenCodeRawResponsePart[]; + message?: string; + text?: string; + content?: string; + structured?: Record; + structuredOutput?: Record; + [key: string]: unknown; +} + export interface MessageEdit { file: string; added?: number; @@ -338,6 +410,15 @@ export interface MessageStep { filePath?: string; } +export interface CentralizedDebugSourceData { + sessionId?: string; + rawSdkEventPayloads?: unknown[]; +} + +export interface CentralizedDebugData { + rawEventStream?: CentralizedDebugSourceData; +} + export interface MessageChangeSummaryFile { file: string; added: number; @@ -374,6 +455,7 @@ export interface ActivityDetail { kind?: "tool_call" | "file_edit" | "command" | "read" | "search" | "other"; summary?: string; command?: string; + input?: Record; output?: string; tool?: string; query?: string; @@ -518,6 +600,7 @@ export interface SubagentSummary { export interface SubagentDetail extends SubagentSummary { thinkingEvents: SubagentThinkingEvent[]; conversationEvents?: SubagentConversationEvent[]; + rawConversationEvents?: unknown[]; progressEvents: SubagentProgressEvent[]; timelineEvents: SubagentTimelineEvent[]; tokenUsage?: { @@ -551,7 +634,8 @@ export interface Message { text?: string; content?: string; - rawResponse?: unknown; + rawResponse?: OpenCodeRawResponse | string; + rawSdkEventPayloads?: unknown[]; reasoningPayload?: { events: ReasoningEvent[]; sources?: Array<"stream" | "final" | "raw_debug">; @@ -700,6 +784,8 @@ export interface AppState { currentSessionId: string | null; messages: Message[]; messagesBySessionId?: Record; + rawMessagesBySessionId?: Record; + rawSdkEventPayloadsBySessionId?: Record; promptQueue: QueueItem[]; queueBySessionId: Record; isExecutingQueue: boolean; // Legacy global flag, to be removed or used carefully diff --git a/webview/shared/src/config.ts b/webview/shared/src/config.ts index 3f1d175..b27946d 100644 --- a/webview/shared/src/config.ts +++ b/webview/shared/src/config.ts @@ -17,7 +17,7 @@ export const config = { * When disabled, all console/terminal/browser logging output is suppressed * @default true */ - showBrowserConsole: false, + showBrowserConsole: true, /** * Show pre-render debug panel showing data fed into the AI response card * (helps debug reasoning leaks into response content) @@ -36,7 +36,7 @@ export const config = { * @default false */ showInteractiveEventsDebug: false, - showCentralizedDebug: false, + showCentralizedDebug: true, }, }; diff --git a/webview/shared/src/diff-review/DiffReviewShell.tsx b/webview/shared/src/diff-review/DiffReviewShell.tsx deleted file mode 100644 index 29bc046..0000000 --- a/webview/shared/src/diff-review/DiffReviewShell.tsx +++ /dev/null @@ -1,1052 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Check, - ChevronDown, - ChevronRight, - Copy, - ExternalLink, - FileCode, - FilePlus, - FileMinus, - GitMerge, - MessageSquare, - Minus, - Plus, - X, - CheckCircle2, - XCircle, - Keyboard, -} from 'lucide-react'; - -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { cn } from '@/utils'; -import { DiffStats } from '@/chat/DiffStats'; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface PlanComment { - id: string; - anchor: { - startLine: number; - endLine: number; - selectedText: string; - surroundingText?: string; - }; - text: string; - createdAt: number; -} - -interface DiffHunk { - header: string; - lines: string[]; -} - -interface DiffFile { - path: string; - added: number; - deleted: number; - type?: 'create' | 'modify' | 'delete'; - hunks: DiffHunk[]; -} - -interface DiffData { - files: DiffFile[]; - comments?: PlanComment[]; -} - -declare global { - interface Window { - __DIFF_DATA__?: DiffData; - } -} - -import vscode from '@/chat/lib/vscode'; - -type FilterType = 'all' | 'create' | 'modify' | 'delete'; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function getFileTypeConfig(type?: DiffFile['type']) { - switch (type) { - case 'create': - return { - label: 'CREATE', - badgeVariant: 'success' as const, - Icon: FilePlus, - colorClass: 'text-oc-green', - }; - case 'delete': - return { - label: 'DELETE', - badgeVariant: 'error' as const, - Icon: FileMinus, - colorClass: 'text-oc-red', - }; - default: - return { - label: 'MODIFY', - badgeVariant: 'muted' as const, - Icon: FileCode, - colorClass: 'text-oc-text-muted', - }; - } -} - -// Parse actual old/new line numbers from hunk header: @@ -old,count +new,count @@ -function parseHunkHeader(header: string): { oldStart: number; newStart: number } { - const m = header.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (m) return { oldStart: parseInt(m[1], 10), newStart: parseInt(m[2], 10) }; - return { oldStart: 1, newStart: 1 }; -} - -// Compute line numbers for all lines in all hunks -function computeLineNumbers(hunks: DiffHunk[]): Array<{ old: number | null; new: number | null }> { - const result: Array<{ old: number | null; new: number | null }> = []; - for (const hunk of hunks) { - const { oldStart, newStart } = parseHunkHeader(hunk.header); - // Push null for the hunk header itself - result.push({ old: null, new: null }); - let oldN = oldStart; - let newN = newStart; - for (const line of hunk.lines) { - if (line.startsWith('+') && !line.startsWith('+++')) { - result.push({ old: null, new: newN++ }); - } else if (line.startsWith('-') && !line.startsWith('---')) { - result.push({ old: oldN++, new: null }); - } else { - result.push({ old: oldN++, new: newN++ }); - } - } - } - return result; -} - -// ── Diff Line ───────────────────────────────────────────────────────────────── - -function DiffLine({ - line, - oldNum, - newNum, -}: { - line: string; - oldNum: number | null; - newNum: number | null; -}) { - const isAdded = line.startsWith('+') && !line.startsWith('+++'); - const isRemoved = line.startsWith('-') && !line.startsWith('---'); - const isHunkHeader = line.startsWith('@@'); - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - const text = line.slice(1); // strip +/- prefix - navigator.clipboard.writeText(text).catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - }; - - return ( -
- {/* Gutter: line numbers */} -
- - {oldNum ?? ''} - - - {newNum ?? ''} - -
- - {/* Sign gutter */} -
- {isAdded ? '+' : isRemoved ? '-' : isHunkHeader ? '·' : ' '} -
- - {/* Content */} -
- {isHunkHeader ? line : line.slice(1)} -
- - {/* Copy button (hover) */} - {!isHunkHeader && ( - - )} -
- ); -} - -// ── Diff Stats Bar ───────────────────────────────────────────────────────────── - -function DiffStatsBar({ added, deleted }: { added: number; deleted: number }) { - const total = added + deleted; - if (total === 0) return null; - const segments = Math.min(5, total); - const addedSegs = Math.round((added / total) * segments); - const deletedSegs = segments - addedSegs; - - return ( -
- {Array.from({ length: addedSegs }).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: decorative segments have no unique id - - ))} - {Array.from({ length: deletedSegs }).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: decorative segments have no unique id - - ))} -
- ); -} - -// ── Diff Item ───────────────────────────────────────────────────────────────── - -function DiffItem({ - file, - isActive, - onApprove, - onReject, - onActivate, - externalExpanded, - setExternalExpanded, -}: { - file: DiffFile; - isActive: boolean; - onApprove: (path: string) => void; - onReject: (path: string) => void; - onActivate: () => void; - externalExpanded?: boolean; - setExternalExpanded?: (v: boolean) => void; -}) { - const [internalExpanded, setInternalExpanded] = useState(false); - const expanded = externalExpanded !== undefined ? externalExpanded : internalExpanded; - const setExpanded = setExternalExpanded ?? setInternalExpanded; - const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null); - const itemRef = useRef(null); - - const { label: typeLabel, badgeVariant, Icon: TypeIcon } = getFileTypeConfig(file.type); - const filename = file.path.split(/[\\/]/).pop() ?? file.path; - const dirname = file.path.includes('/') || file.path.includes('\\') - ? file.path.substring(0, file.path.lastIndexOf(file.path.includes('/') ? '/' : '\\')) - : ''; - - const allLinesAndNums = (() => { - const nums = computeLineNumbers(file.hunks); - const flat: string[] = []; - for (const hunk of file.hunks) { - flat.push(hunk.header); - flat.push(...hunk.lines); - } - return flat.map((line, i) => ({ line, ...nums[i] })); - })(); - - // Scroll into view when becoming active - useEffect(() => { - if (isActive && itemRef.current) { - itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }, [isActive]); - - return ( -
- {/* File header */} -
{ - if ((e.target as HTMLElement).closest('button')) return; - onActivate(); - }} - > - - -
- {/* Type badge */} - - {typeLabel} - - - {/* Stats bar */} - - - {/* +/- counts */} - - - {decided ? ( - - {decided === 'approved' ? ( - <>Approved - ) : ( - <>Rejected - )} - - ) : ( -
- - - -
- )} -
-
- - {/* Diff content */} -
-
- {allLinesAndNums.length > 0 ? ( - allLinesAndNums.map(({ line, old: oldNum, new: newNum }, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: diff lines have no stable unique id - - )) - ) : ( -
- No diff content available. -
- )} -
-
-
- ); -} - -// ── Approval Progress Bar ───────────────────────────────────────────────────── - -function ApprovalProgressBar({ - total, - approved, - rejected, -}: { - total: number; - approved: number; - rejected: number; -}) { - if (total === 0) return null; - const pending = total - approved - rejected; - const approvedPct = (approved / total) * 100; - const rejectedPct = (rejected / total) * 100; - - return ( -
-
-
-
-
- - {approved}/{total} reviewed - {pending > 0 && · {pending} pending} - -
- ); -} - -// ── Keyboard Shortcuts Hint ─────────────────────────────────────────────────── - -function KeyboardHint() { - const [visible, setVisible] = useState(false); - - return ( -
- - {visible && ( -
-
- {[ - ['j / ↓', 'Next file'], - ['k / ↑', 'Prev file'], - ['Enter', 'Expand/collapse'], - ['Esc', 'Close panels'], - ['a', 'Approve active'], - ['r', 'Reject active'], - ].map(([key, desc]) => ( -
- {key} - {desc} -
- ))} -
-
- )} -
- ); -} - -// ── Main Shell ──────────────────────────────────────────────────────────────── - -export default function DiffReviewShell() { - const data = window.__DIFF_DATA__; - const [filter, setFilter] = useState('all'); - const [comments, setComments] = useState(data?.comments ?? []); - const [commentsPanelOpen, setCommentsPanelOpen] = useState(false); - const [editingId, setEditingId] = useState(null); - const [editText, setEditText] = useState(''); - - // Keyboard navigation - const [activeIdx, setActiveIdx] = useState(0); - const [expandedMap, setExpandedMap] = useState>({}); - - // Decisions tracking for approval bar - const [decisions, setDecisions] = useState>({}); - - const handleApprove = useCallback((path: string) => { - setDecisions((d) => ({ ...d, [path]: 'approved' })); - vscode?.postMessage({ type: 'approveDiff', file: path }); - }, []); - - const handleReject = useCallback((path: string) => { - setDecisions((d) => ({ ...d, [path]: 'rejected' })); - vscode?.postMessage({ type: 'rejectDiff', file: path }); - }, []); - - const shellRef = useRef(null); - const [pendingAnchor, setPendingAnchor] = useState(null); - const [popoverPos, setPopoverPos] = useState<{ x: number; y: number } | null>(null); - const [commentText, setCommentText] = useState(''); - - // Listen for commentsUpdated messages - useEffect(() => { - function handler(e: MessageEvent) { - if (e.data?.type === 'commentsUpdated') setComments(e.data.comments ?? []); - } - window.addEventListener('message', handler); - return () => window.removeEventListener('message', handler); - }, []); - - // Compute filtered files - const filteredFiles = filter === 'all' - ? (data?.files ?? []) - : (data?.files ?? []).filter((f) => (f.type ?? 'modify') === filter); - - // Keyboard navigation - useEffect(() => { - function handleKey(e: KeyboardEvent) { - const active = document.activeElement; - const isInput = active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA' || active?.closest('.comment-popover'); - if (isInput) return; - - switch (e.key) { - case 'j': - case 'ArrowDown': - e.preventDefault(); - setActiveIdx((i) => Math.min(i + 1, filteredFiles.length - 1)); - break; - case 'k': - case 'ArrowUp': - e.preventDefault(); - setActiveIdx((i) => Math.max(i - 1, 0)); - break; - case 'Enter': { - e.preventDefault(); - const file = filteredFiles[activeIdx]; - if (file) { - setExpandedMap((m) => ({ ...m, [file.path]: !m[file.path] })); - } - break; - } - case 'a': { - const file = filteredFiles[activeIdx]; - if (file && !decisions[file.path]) { - handleApprove(file.path); - } - break; - } - case 'r': { - const file = filteredFiles[activeIdx]; - if (file && !decisions[file.path]) { - handleReject(file.path); - } - break; - } - case 'Escape': - setCommentsPanelOpen(false); - setPendingAnchor(null); - setPopoverPos(null); - setCommentText(''); - break; - } - } - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [filteredFiles, activeIdx, decisions, handleApprove, handleReject]); - - // Text selection → floating comment popover - useEffect(() => { - function computeAnchorFromSelection() { - const sel = window.getSelection(); - if (!sel || sel.isCollapsed) { - setPendingAnchor(null); - setPopoverPos(null); - return; - } - - const container = shellRef.current; - if (!container || !sel.anchorNode || !sel.focusNode || !container.contains(sel.anchorNode)) { - setPendingAnchor(null); - setPopoverPos(null); - return; - } - - const selectedText = sel.toString().trim(); - if (!selectedText) { - setPendingAnchor(null); - setPopoverPos(null); - return; - } - - const surroundingText = sel.getRangeAt(0).commonAncestorContainer.textContent || ''; - setPendingAnchor({ startLine: 0, endLine: 0, selectedText, surroundingText }); - - try { - const range = sel.getRangeAt(0); - const rect = range.getBoundingClientRect(); - const POPOVER_W = 308; - const POPOVER_H = 160; - const x = Math.min(Math.max(8, rect.left), window.innerWidth - POPOVER_W - 8); - const y = Math.max(8, rect.top - POPOVER_H - 8); - setPopoverPos({ x, y }); - } catch { - /* ignore */ - } - } - - function handleSelectionChange() { - const active = document.activeElement; - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.closest('.comment-popover'))) return; - const sel = window.getSelection(); - if (!sel || sel.isCollapsed) { - setPendingAnchor(null); - setPopoverPos(null); - } - } - - const container = shellRef.current; - if (container) { - container.addEventListener('mouseup', computeAnchorFromSelection); - container.addEventListener('keyup', computeAnchorFromSelection); - } - document.addEventListener('selectionchange', handleSelectionChange); - return () => { - if (container) { - container.removeEventListener('mouseup', computeAnchorFromSelection); - container.removeEventListener('keyup', computeAnchorFromSelection); - } - document.removeEventListener('selectionchange', handleSelectionChange); - }; - }, []); - - // Comment highlight walk - useEffect(() => { - const container = shellRef.current; - if (!container || !comments.length) return; - - const walk = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.nodeValue || ''; - const parent = node.parentNode as HTMLElement; - if (!parent || parent.nodeName === 'MARK' || parent.nodeName === 'SCRIPT' || parent.nodeName === 'STYLE' || parent.closest('.comment-popover')) return; - - for (const comment of comments) { - const needle = comment.anchor.selectedText; - if (!needle) continue; - const idx = text.indexOf(needle); - if (idx !== -1) { - if (comment.anchor.surroundingText) { - const context = parent.innerText || parent.textContent || ''; - if (!context.includes(comment.anchor.surroundingText)) continue; - } - const before = text.slice(0, idx); - const match = text.slice(idx, idx + needle.length); - const after = text.slice(idx + needle.length); - const fragment = document.createDocumentFragment(); - if (before) fragment.appendChild(document.createTextNode(before)); - const mark = document.createElement('mark'); - mark.textContent = match; - mark.className = 'bg-amber-500/30 text-inherit cursor-pointer rounded-sm hover:bg-amber-500/50 transition-colors px-0.5 -mx-0.5'; - mark.onclick = (e) => { e.stopPropagation(); setCommentsPanelOpen(true); }; - fragment.appendChild(mark); - if (after) fragment.appendChild(document.createTextNode(after)); - parent.replaceChild(fragment, node); - break; - } - } - } else { - Array.from(node.childNodes).forEach(walk); - } - }; - - const timer = setTimeout(() => walk(container), 50); - return () => clearTimeout(timer); - }, [comments]); - - if (!data || data.files.length === 0) { - return ( -
- - No diff data available. -
- ); - } - - const filters: { label: string; value: FilterType }[] = [ - { label: 'All', value: 'all' }, - { label: 'Created', value: 'create' }, - { label: 'Modified', value: 'modify' }, - { label: 'Deleted', value: 'delete' }, - ]; - - const totalAdded = data.files.reduce((sum, f) => sum + (f.added ?? 0), 0); - const totalDeleted = data.files.reduce((sum, f) => sum + (f.deleted ?? 0), 0); - const approvedCount = Object.values(decisions).filter((d) => d === 'approved').length; - const rejectedCount = Object.values(decisions).filter((d) => d === 'rejected').length; - - function handleAddComment() { - const trimmed = commentText.trim(); - if (!trimmed || !pendingAnchor) return; - const newComment: PlanComment = { - id: crypto.randomUUID(), - anchor: pendingAnchor, - text: trimmed, - createdAt: Date.now(), - }; - vscode?.postMessage({ type: 'addComment', comment: newComment }); - setCommentText(''); - setPendingAnchor(null); - setPopoverPos(null); - } - - return ( -
- {/* ── Header ── */} -
- {/* Top row */} -
-
-
- -
-

Diff Review

- - {data.files.length} file{data.files.length !== 1 ? 's' : ''} - -
- -
- {/* Total stats */} - - -
- - - - -
-
- - {/* Approval progress */} - - - {/* Filter tabs */} -
- {filters.map((f) => { - const count = f.value === 'all' - ? data.files.length - : data.files.filter((file) => (file.type ?? 'modify') === f.value).length; - if (count === 0 && f.value !== 'all') return null; - return ( - - ); - })} -
-
- - {/* ── File list ── */} -
- {filteredFiles.length === 0 ? ( -
- - No files match this filter. -
- ) : ( - filteredFiles.map((file, i) => ( - setActiveIdx(i)} - externalExpanded={expandedMap[file.path] ?? false} - setExternalExpanded={(v) => - setExpandedMap((m) => ({ ...m, [file.path]: v })) - } - /> - )) - )} -
- - {/* ── Floating comment popover ── */} - {popoverPos && pendingAnchor && ( -
-

- “{pendingAnchor.selectedText.length > 60 - ? `${pendingAnchor.selectedText.slice(0, 60)}…` - : pendingAnchor.selectedText}” -

-