diff --git a/package-lock.json b/package-lock.json index 6c41ff7..9f5dc78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.16", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "^1.15.12", + "@opencode-ai/sdk": "1.15.13", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "axios": "^1.13.6", @@ -325,7 +325,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -342,7 +341,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -359,7 +357,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -376,7 +373,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -393,7 +389,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -410,7 +405,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -427,7 +421,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -444,7 +437,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -461,7 +453,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -478,7 +469,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -495,7 +485,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -512,7 +501,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -529,7 +517,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -546,7 +533,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -563,7 +549,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -580,7 +565,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -597,7 +581,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -614,7 +597,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -631,7 +613,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -648,7 +629,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -665,7 +645,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -682,7 +661,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -699,7 +677,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -716,7 +693,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -733,7 +709,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -750,7 +725,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1047,9 +1021,10 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.12", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz", - "integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==", + "version": "1.15.13", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.13.tgz", + "integrity": "sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==", + "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" } @@ -4080,7 +4055,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4097,7 +4071,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4114,7 +4087,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4131,7 +4103,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4148,7 +4119,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4165,7 +4135,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4182,7 +4151,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4199,7 +4167,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4216,7 +4183,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4233,7 +4199,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4250,7 +4215,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4267,7 +4231,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4284,7 +4247,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4301,7 +4263,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4318,7 +4279,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4335,7 +4295,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4352,7 +4311,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4369,7 +4327,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4386,7 +4343,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4403,7 +4359,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4420,7 +4375,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4437,7 +4391,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4454,7 +4407,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4471,7 +4423,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4488,7 +4439,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4505,7 +4455,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 1a7c874..f7c7d09 100644 --- a/package.json +++ b/package.json @@ -308,7 +308,7 @@ "typescript": "^5.3.2" }, "dependencies": { - "@opencode-ai/sdk": "^1.15.12", + "@opencode-ai/sdk": "1.15.13", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "axios": "^1.13.6", diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 7f78895..9d553f3 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -95,6 +95,7 @@ */ import type { FileDiff, SessionPromptData } from "@opencode-ai/sdk" with { "resolution-mode": "import" }; +import type { OutputFormatJsonSchema } from "@opencode-ai/sdk/v2"; import * as cp from "child_process"; import * as path from "path"; import * as vscode from "vscode"; @@ -112,7 +113,12 @@ import { MessageStreamService } from "../services/MessageStreamService"; import { ModelCapabilitiesService } from "../services/ModelCapabilitiesService"; import { OpencodeServerManager } from "../services/OpencodeServerManager"; import { QuotaService } from "../services/QuotaService"; +import { + adaptSdkMessages, + adaptSubtaskPart as sdkAdaptSubtaskPart, +} from "../services/SdkMessageAdapter"; import { SessionService } from "../services/SessionService"; +import { SessionSnapshotLoader } from "../services/SessionSnapshotLoader"; import { SkillManagerService } from "../services/SkillManagerService"; import { SkillManagementService } from "../services/SkillManagementService"; import { @@ -259,7 +265,16 @@ export class ChatViewProvider /** Service for monitoring AI platform quota usage */ private quotaService: QuotaService; + private sessionSnapshotLoader: SessionSnapshotLoader; private subagentTracker: SubagentTracker; + private readonly streamedSubtaskPartsBySessionId = new Map; + }>(); + private readonly pendingSdkSubagentRefreshes = new Set(); /** Sessions whose persisted live subagents were finalized for this host instance. */ private recoveredSubagentSessions = new Set(); @@ -313,6 +328,7 @@ export class ChatViewProvider * cross-session event leakage when the user switches sessions while a * response is still streaming from the server. */ private activeStreamSessionId: string | undefined; + private turnEpochBySession = new Map(); private pendingStreamWebviewEvents: Array<{ event: unknown; sessionId?: string }> = []; private streamWebviewFlushTimer: ReturnType | undefined; private lastStreamPerformanceLogAt = 0; @@ -322,8 +338,6 @@ export class ChatViewProvider */ private pendingLiveEventDebugEvents: Array<{ event: unknown; sessionId?: string }> = []; private liveEventDebugFlushTimer: ReturnType | undefined; - private pendingRawSdkPersistenceBySessionId = new Map(); - private rawSdkPersistenceFlushTimer: ReturnType | undefined; private currentTodoItems: unknown[] = []; private compatibilityWarningsOverride: CompatibilityResult[] | null = null; @@ -407,45 +421,69 @@ export class ChatViewProvider return `${value.slice(0, limit)}\n\n[Output truncated in the live chat view: ${value.length - limit} characters omitted]`; } - private buildWebviewStreamEvent(event: unknown, sessionId: string | undefined): unknown { - const eventRecord = this.asRecord(event); - if (!eventRecord) { - return event; - } - const trimPart = (value: unknown): unknown => { - const part = this.asRecord(value); - if (!part) return value; - const state = this.asRecord(part.state); - const metadata = this.asRecord(part.metadata); - const trimmedState = state - ? { - ...state, - output: this.truncateStreamToolTextForWebview(state.output), - result: this.truncateStreamToolTextForWebview(state.result), - } - : state; - const trimmedMetadata = metadata - ? { - ...metadata, - preview: this.truncateStreamToolTextForWebview(metadata.preview), - } - : metadata; - return { - ...part, - output: this.truncateStreamToolTextForWebview(part.output), - state: trimmedState, - metadata: trimmedMetadata, - }; - }; - const properties = this.asRecord(eventRecord.properties); - return { - ...eventRecord, - sessionId, - part: trimPart(eventRecord.part), - properties: properties - ? { ...properties, part: trimPart(properties.part) } - : eventRecord.properties, + // Depth cap for cloneAndTruncateStreamPayload. SDK events are flat-ish + // (type/properties/part/info/structured/message — typically 3-4 levels), + // but we leave headroom for nested tool inputs. Removing this cap risks + // unbounded recursion on adversarial payloads. + private static readonly STREAM_PAYLOAD_MAX_CLONE_DEPTH = 16; + + /** + * Build a webview-bound stream event with oversized string fields bounded + * to MAX_STREAM_WEBVIEW_TOOL_OUTPUT_CHARS. The persisted SDK event (the + * original reference passed to handleStreamEvent) is never mutated — only + * the webview-bound copy is slimmed. + * + * Perf rationale: VS Code's postMessage uses structured-clone serialization + * across the webview IPC boundary. On large SDK events (full accumulated + * message content, large tool outputs, full file reads) serialization + * blocks the extension host main thread for 30-60ms — empirically + * measured as the dominant source of streaming freezes via the + * `[STREAM-PERF] provider-webview-flush` metric. + * + * Contract rationale: webview still receives full content via `chatHistory` + * after the stream completes, and MarkdownRenderer already bounds its own + * rendering at 150KB, so per-field truncation at 16KB during streaming is + * a safe conservative cap that preserves all product behaviour. + * + * Removing this method, or bypassing it on the postMessage path, will + * reintroduce the streaming freezes documented in + * tests/webview/stream-event-main-thread-performance.test.mjs. + */ + private buildWebviewStreamEvent(enrichedEvent: unknown): unknown { + if (!enrichedEvent || typeof enrichedEvent !== "object") { + return enrichedEvent; + } + // Per the webview transport contract: centralizedEventPayload is built by + // spreading enrichedEvent first, then layering on the truncated deep + // clone. Last-write-wins means truncated values overwrite the originals + // for any oversized field. + const truncatedDeepClone = this.cloneAndTruncateStreamPayload(enrichedEvent); + const centralizedEventPayload = { + ...enrichedEvent, + ...(truncatedDeepClone as Record), }; + return centralizedEventPayload; + } + + private cloneAndTruncateStreamPayload(node: unknown, depth = 0): unknown { + if (depth > ChatViewProvider.STREAM_PAYLOAD_MAX_CLONE_DEPTH) { + return node; + } + if (typeof node === "string") { + return this.truncateStreamToolTextForWebview(node); + } + if (Array.isArray(node)) { + return node.map((entry) => this.cloneAndTruncateStreamPayload(entry, depth + 1)); + } + if (node && typeof node === "object") { + const out: Record = {}; + const rec = node as Record; + for (const key of Object.keys(rec)) { + out[key] = this.cloneAndTruncateStreamPayload(rec[key], depth + 1); + } + return out; + } + return node; } private flushStreamWebviewEvents(): void { @@ -541,59 +579,6 @@ export class ChatViewProvider }); } - private enqueueRawSdkEventPersistence( - sessionId: string, - event: unknown, - flushImmediately = false, - ): void { - const pending = this.pendingRawSdkPersistenceBySessionId.get(sessionId) ?? []; - pending.push(event); - this.pendingRawSdkPersistenceBySessionId.set(sessionId, pending); - - if (flushImmediately) { - this.flushRawSdkEventPersistence(); - return; - } - - if (this.rawSdkPersistenceFlushTimer) { - return; - } - - this.rawSdkPersistenceFlushTimer = setTimeout(() => { - this.flushRawSdkEventPersistence(); - }, 100); - } - - private flushRawSdkEventPersistence(): void { - if (this.rawSdkPersistenceFlushTimer) { - clearTimeout(this.rawSdkPersistenceFlushTimer); - this.rawSdkPersistenceFlushTimer = undefined; - } - - if (this.pendingRawSdkPersistenceBySessionId.size === 0) { - return; - } - - const pendingBySession = this.pendingRawSdkPersistenceBySessionId; - this.pendingRawSdkPersistenceBySessionId = new Map(); - - for (const [sessionId, events] of pendingBySession.entries()) { - if (events.length === 0) { - continue; - } - void this.sessionService.appendRawSdkEventPayloads( - sessionId, - events, - ).catch((error) => { - this.logger.error("[CENTRALIZED-TAPE][HOST] raw_event_store_append_failed", { - sessionId, - eventCount: events.length, - error: error instanceof Error ? error.message : String(error), - }); - }); - } - } - /** * Create a stable optimistic message identifier for the local conversation * cache. @@ -701,6 +686,78 @@ export class ChatViewProvider } } + private async refreshPendingInteractionsFromSdk(sessionId: string): Promise { + if (!sessionId) { + return; + } + + const extractList = (response: unknown, fallbackKey: "questions" | "permissions"): unknown[] => { + if (Array.isArray(response)) { + return response; + } + const responseRecord = this.asRecord(response); + if (!responseRecord) { + return []; + } + if (Array.isArray(responseRecord.data)) { + return responseRecord.data; + } + if (Array.isArray(responseRecord[fallbackKey])) { + return responseRecord[fallbackKey] as unknown[]; + } + return []; + }; + + const belongsToSession = (entry: unknown): boolean => { + const record = this.asRecord(entry); + return this.firstNonEmptyString(record?.sessionID, record?.sessionId) === sessionId; + }; + + try { + const client = await this.serverManager.ensureRunning(); + let questions: unknown[] = []; + let permissions: unknown[] = []; + + try { + const questionResponse = await (client as any).question.list(); + questions = extractList(questionResponse, "questions").filter(belongsToSession); + } catch (questionError) { + this.logger.warn("Failed to list pending SDK questions", { + sessionId, + error: questionError instanceof Error ? questionError.message : String(questionError), + }); + } + + try { + const permissionResponse = await (client as any).permission.list(); + permissions = extractList(permissionResponse, "permissions").filter(belongsToSession); + } catch (permissionError) { + this.logger.warn("Failed to list pending SDK permissions", { + sessionId, + error: permissionError instanceof Error ? permissionError.message : String(permissionError), + }); + } + + this.view?.webview.postMessage({ + type: "pendingInteractions", + sessionId, + questions, + permissions, + }); + + this.logger.debug("Refreshed pending interactions from SDK", { + sessionId, + questionCount: questions.length, + permissionCount: permissions.length, + }); + } catch (error) { + this.logger.warn("Failed to refresh pending interactions from SDK", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async handleSdkTodoUpdatedEvent( event: unknown, fallbackSessionId?: string, @@ -803,7 +860,14 @@ export class ChatViewProvider images?: any[]; agent?: string; }; - private structuredOutputMode: "format" | "outputFormat" | "disabled" = "format"; + /** OpenCode v2 accepts structured output only through the typed `format` field. */ + private structuredOutputMode: "format" | "disabled" = "format"; + /** + * A successful prompt is insufficient: OpenCode persists `format` on the + * user message and validates it again in `session.messages()`. Cache the + * round-trip check so each server lifecycle pays for one no-reply probe. + */ + private structuredOutputFormatCompatibility?: Promise; private readonly promptDebugBySession = new Map>(); private readonly structuredValidationFailureCounters = new Map(); private readonly structuredOutputIncompatibleModelKeys = new Set(); @@ -862,6 +926,7 @@ export class ChatViewProvider this.logger = createLogger(LoggingCategories.CHAT_VIEW); this.streamService = new MessageStreamService(serverManager); this.quotaService = new QuotaService(); + this.sessionSnapshotLoader = new SessionSnapshotLoader(serverManager); this.subagentTracker = new SubagentTracker(() => this.selectedModel); this.configFilesProvider = new ConfigFilesProvider(); this.skillManager = new SkillManagerService(context); @@ -994,10 +1059,10 @@ export class ChatViewProvider // 8. SessionHandler this.sessionHandler = new SessionHandler( this.sessionService, - this.historyProcessor, this.compactionManager, this.modelAndAgentManager, logger, + this.sessionSnapshotLoader, ); // 9. StreamEventHandler @@ -1006,7 +1071,6 @@ export class ChatViewProvider this.compactionManager, this.diagnosticsLogger, this.geminiTokenTracker, - this.sessionService, logger, ); @@ -1171,41 +1235,10 @@ export class ChatViewProvider const isMainTurnProcessing = this.isSessionMainTurnProcessing(sessionId); let effectiveMode = mode; - // The SDK exposes session status as the authoritative live-turn signal. - // Do not infer a queue from transcript/message IDs or from our local - // processing set: both can lag behind the server and incorrectly label an - // ordinary turn as queued. The SDK has no per-message queue status, so once - // it reports busy/retry the extension QueueManager owns each queued item. - if ( - mode === "send-now" && - !payload.interactiveSubmit && - !payload.forceSendNow - ) { - try { - const client = await this.serverManager.ensureRunning(); - const statusResponse = await client.session.status({ - ...(this.getWorkspaceDirectory() - ? { directory: this.getWorkspaceDirectory() } - : {}), - }); - const statusBySession = - (statusResponse as any)?.data ?? statusResponse; - const statusType = - statusBySession && typeof statusBySession === "object" - ? (statusBySession as Record)[sessionId]?.type - : undefined; - if (statusType === "busy" || statusType === "retry") { - effectiveMode = "queue"; - } - } catch (error) { - // Preserve the existing direct-send behavior if status cannot be read; - // a failed status lookup must not manufacture a queue state. - this.logger.debug("[MessageFlow] SDK session status unavailable", { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); - } - } + // Normal composer messages go straight to OpenCode even during an active + // turn. Its native transcript/order handling is the only queue presented + // to the user; the extension QueueManager is reserved for explicit legacy + // queue commands and must not create a second pending-message UI. this.logger.debug('[MessageFlow] Mode resolution', { requestedMode: mode, @@ -1479,11 +1512,22 @@ export class ChatViewProvider } try { - const rawMessages = await this.sessionService.getMessages(childSessionId); - const processedMessages = await this.processHistoryMessages( - Array.isArray(rawMessages) ? rawMessages : [], - childSessionId, - ); + let processedMessages: any[]; + try { + const sdkMessages = await this.sessionSnapshotLoader.loadMessagesOnly(childSessionId); + processedMessages = adaptSdkMessages(sdkMessages); + } catch (sdkError) { + this.logger.warn("SDK child session transcript load failed; falling back", { + subagentId, + childSessionId, + error: sdkError instanceof Error ? sdkError.message : String(sdkError), + }); + const rawMessages = await this.sessionService.getMessages(childSessionId); + processedMessages = await this.processHistoryMessages( + Array.isArray(rawMessages) ? rawMessages : [], + childSessionId, + ); + } const conversationEvents = this.buildAssistantConversationEvents( processedMessages, @@ -1655,10 +1699,251 @@ export class ChatViewProvider return events; } - /** - * Wrapper: Load session - * Loads messages from service and sends to webview - */ + private markTurnStreamStarted(sessionId: string): void { + const currentEpoch = (this.turnEpochBySession.get(sessionId) ?? 0) + 1; + this.turnEpochBySession.set(sessionId, currentEpoch); + } + + private async schedulePostTurnSdkRefresh(sessionId: string, _messageId?: string): Promise { + const epoch = this.turnEpochBySession.get(sessionId) ?? 0; + // Small delay to let the server finalize the message. + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Check if a new turn started during the delay. + if (this.turnEpochBySession.get(sessionId) !== epoch) return; + + try { + // A complete session snapshot is cheap compared with replaying a raw + // event tape and uses the established chatHistory protocol, avoiding a + // second, partially handled finalization message type. + const history = await this.loadSdkRenderableHistory(sessionId); + if (!history.available) { + this.logger.warn("Post-turn SDK refresh skipped: snapshot unavailable", { + sessionId, + }); + return; + } + this.view?.webview.postMessage({ + type: "chatHistory", + sessionId, + messages: history.messages, + sdkMessages: history.sdkMessages, + contextInputTokens: history.contextInputTokens, + processingSessionIds: this.getEffectiveProcessingSessionIds(), + }); + await this.refreshPendingInteractionsFromSdk(sessionId); + } catch (err) { + this.logger.warn("Post-turn SDK refresh failed", { + sessionId, + error: String(err), + }); + } + } + + private captureStreamedSubtaskPart( + parentSessionId: string | undefined, + properties: Record, + part: Record, + ): void { + if ((typeof part?.type === "string" ? part.type.toLowerCase() : "") !== "subtask") { + return; + } + const childSessionId = this.firstNonEmptyString(part.sessionID, part.sessionId, part.childSessionId); + if (!childSessionId) { + return; + } + const info = this.asRecord(properties.info) || {}; + this.streamedSubtaskPartsBySessionId.set(childSessionId, { + sessionID: childSessionId, + messageID: this.firstNonEmptyString(part.messageID, part.messageId, info.id), + agent: this.firstNonEmptyString(part.agent), + parentSessionId, + part: { ...part }, + }); + } + + private scheduleSdkSubagentChildrenRefresh(parentSessionId: string): void { + const hasSubtaskForParent = Array.from(this.streamedSubtaskPartsBySessionId.values()).some( + (metadata) => metadata.parentSessionId === parentSessionId, + ); + if (!hasSubtaskForParent || this.pendingSdkSubagentRefreshes.has(parentSessionId)) { + return; + } + + this.pendingSdkSubagentRefreshes.add(parentSessionId); + setTimeout(() => { + void this.refreshSubagentsFromSdkChildren(parentSessionId).finally(() => { + this.pendingSdkSubagentRefreshes.delete(parentSessionId); + }); + }, 200); + } + + private async refreshSubagentsFromSdkChildren(parentSessionId: string): Promise { + try { + const snapshot = await this.sessionSnapshotLoader.loadSnapshot(parentSessionId); + const childrenById = new Map(); + for (const child of snapshot.children ?? []) { + const childSession = child?.session; + if (typeof childSession?.id === "string" && childSession.id.length > 0) { + childrenById.set(childSession.id, childSession); + } + } + + if (childrenById.size === 0) { + return; + } + + const detailsById: Record> = {}; + const subagentDetails: Record[] = []; + const subagentsByParentMessageId: Record[]> = {}; + + for (const sdkMessage of snapshot.messages ?? []) { + const info = sdkMessage?.info as any; + const parts = Array.isArray(sdkMessage?.parts) ? sdkMessage.parts : []; + for (const candidatePart of parts) { + const subtaskPart = candidatePart as any; + if (subtaskPart?.type !== "subtask") { + continue; + } + const childSessionId = this.firstNonEmptyString(subtaskPart.sessionID, subtaskPart.sessionId); + const childSession = childSessionId ? childrenById.get(childSessionId) : undefined; + if (!childSessionId || !childSession) { + continue; + } + + const baseDetail = sdkAdaptSubtaskPart(subtaskPart, info); + const detail = this.enrichSdkSubtaskDetail(baseDetail as any, subtaskPart, childSession, parentSessionId); + detailsById[detail.id as string] = detail; + subagentDetails.push(detail); + const parentMessageId = this.firstNonEmptyString(detail.parentMessageId, subtaskPart.messageID, info?.id); + if (parentMessageId) { + subagentsByParentMessageId[parentMessageId] = [ + ...(subagentsByParentMessageId[parentMessageId] ?? []), + detail, + ]; + } + } + } + + if (subagentDetails.length === 0) { + return; + } + + this.view?.webview.postMessage({ + type: "subagentHydrationUpdate", + sessionId: parentSessionId, + subagentDetails, + }); + this.view?.webview.postMessage({ + type: "subagentUpdate", + sessionId: parentSessionId, + detailsById, + subagentDetailsById: detailsById, + subagentsByParentMessageId, + }); + } catch (error) { + this.logger.warn("SDK child-session subagent refresh failed", { + sessionId: parentSessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private enrichSdkSubtaskDetail( + baseDetail: Record, + part: Record, + childSession: any, + parentSessionId: string, + ): Record { + const childTime = this.asRecord(childSession?.time) || {}; + const childModel = this.asRecord(childSession?.model) || {}; + const partModel = this.asRecord(part?.model) || {}; + const title = this.firstNonEmptyString( + childSession?.title, + part.description, + part.prompt, + childSession?.id, + ) || "Subagent"; + const status = typeof childTime.completed === "number" + ? "done" + : typeof childTime.updated === "number" + ? "running" + : this.normalizeSubagentStatus(baseDetail.status); + + return { + ...baseDetail, + id: this.firstNonEmptyString(baseDetail.id, childSession?.id) || title, + name: title, + title, + parentSessionId, + parentMessageId: this.firstNonEmptyString(baseDetail.parentMessageId, part.messageID), + childSessionId: childSession?.id, + agentId: this.firstNonEmptyString(baseDetail.agentId, part.agent), + agent: this.firstNonEmptyString(part.agent, baseDetail.agentId), + providerID: this.firstNonEmptyString(childModel.providerID, partModel.providerID, baseDetail.providerID), + modelID: this.firstNonEmptyString(childModel.modelID, partModel.modelID, baseDetail.modelID), + model: childSession?.model ?? part.model, + status, + latestActivity: this.firstNonEmptyString(childSession?.summary, baseDetail.latestActivity, title), + tokens: childSession?.tokens, + cost: childSession?.cost, + summary: childSession?.summary, + createdAt: typeof childTime.created === "number" ? childTime.created : undefined, + updatedAt: typeof childTime.updated === "number" ? childTime.updated : undefined, + completedAt: typeof childTime.completed === "number" ? childTime.completed : undefined, + }; + } + + private isTerminalStreamState( + eventType: string, + properties: Record, + info: Record, + ): boolean { + const terminalEventTypes = new Set([ + "message.complete", + "message.completed", + "message.error", + "message.aborted", + "session.completed", + "session.error", + "error", + ]); + if (terminalEventTypes.has(eventType)) { + return true; + } + + const normalize = (value: unknown): string | undefined => + typeof value === "string" ? value.trim().toLowerCase() : undefined; + const truthyTerminal = (value: unknown): boolean => + value === true || + ["true", "done", "stop", "complete", "completed", "success", "finished", "finish", "error", "aborted", "abort", "idle"].includes(normalize(value) || ""); + + if (eventType === "session.status") { + const status = normalize( + properties.status ?? + properties.state ?? + (properties.session as Record | undefined)?.status ?? + (properties.info as Record | undefined)?.status, + ); + return status === "idle"; + } + + if (eventType !== "message.updated") { + return false; + } + + return ( + truthyTerminal(info.finish) || + truthyTerminal(info.completed) || + truthyTerminal(info.error) || + truthyTerminal(info.aborted) || + truthyTerminal(properties.finish) || + truthyTerminal(properties.completed) || + truthyTerminal(properties.error) || + truthyTerminal(properties.aborted) + ); + } + private async handleLoadSession(sessionId: string): Promise { const flow = log.startFeatureFlow('LoadSession', { sessionId }); @@ -1700,7 +1985,7 @@ export class ChatViewProvider // ============================================================================ // Step 1: Load and process messages for the new session - const sessionHistory = await this.loadCentralizedRenderableHistory( + const sessionHistory = await this.loadSdkRenderableHistory( sessionId, ); const messages = sessionHistory.messages; @@ -1732,11 +2017,9 @@ export class ChatViewProvider type: "chatHistory", sessionId: sessionId, messages: messages, + sdkMessages: sessionHistory.sdkMessages, contextInputTokens: sessionHistory.contextInputTokens, - rawEventStream: { events: sessionHistory.events }, - subagents: sessionHistory.subagents, - subagentsRecoveredAfterRestart: sessionHistory.subagentsRecoveredAfterRestart, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); await this.compactionManager.sendCompactionViewStateForMessages( @@ -1763,6 +2046,7 @@ export class ChatViewProvider todoItems: [], }); void this.refreshSdkTodosForSession(this.currentSessionId); + void this.refreshPendingInteractionsFromSdk(sessionId); const sessionThinkingLevel = this.modelAndAgentManager.getEffectiveThinkingLevel(sessionId); @@ -2418,7 +2702,7 @@ export class ChatViewProvider /** * Structured output methods - delegate to StructuredOutputProcessor */ - private getStructuredOutputFormat(): Record { + private getStructuredOutputFormat(): OutputFormatJsonSchema { return this.structuredOutputProcessor.getStructuredOutputFormat(); } @@ -3033,25 +3317,23 @@ export class ChatViewProvider // Fetch and send chat history and sessions list if (currentSession) { this.subagentTracker.setActiveSession(currentSession.id); - const sessionHistory = await this.loadCentralizedRenderableHistory( + const sessionHistory = await this.loadSdkRenderableHistory( currentSession.id, ); const messages = sessionHistory.messages; this.logHistoryRenderDiagnostics( "webview.ready.current-session", currentSession.id, - sessionHistory.events, + [], messages, ); this.view?.webview.postMessage({ type: "chatHistory", sessionId: currentSession.id, messages: messages, + sdkMessages: sessionHistory.sdkMessages, contextInputTokens: sessionHistory.contextInputTokens, - rawEventStream: { events: sessionHistory.events }, - subagents: sessionHistory.subagents, - subagentsRecoveredAfterRestart: sessionHistory.subagentsRecoveredAfterRestart, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); await this.sendPersistedCompactionViewState(currentSession.id); @@ -3165,6 +3447,7 @@ export class ChatViewProvider if (replySessionId) { this.processingSessionIds.add(replySessionId); this.activeStreamSessionId = replySessionId; + this.markTurnStreamStarted(replySessionId); this.sendProcessingSessionsUpdate(); } let answerMessageId: string | undefined; @@ -3236,6 +3519,10 @@ export class ChatViewProvider answers, directory: this.getWorkspaceDirectory(), }); + const refreshSessionId = replySessionId || this.currentSessionId; + if (refreshSessionId) { + void this.refreshPendingInteractionsFromSdk(refreshSessionId); + } this.logger.error("[DEBUG][HOST] Successfully executed question.reply"); } @@ -3258,6 +3545,10 @@ export class ChatViewProvider answers, directory: this.getWorkspaceDirectory(), }); + const refreshSessionId = replySessionId || this.currentSessionId; + if (refreshSessionId) { + void this.refreshPendingInteractionsFromSdk(refreshSessionId); + } this.logger.error("[DEBUG][HOST] question.reply to pre-answer DB succeeded", { requestID }); } catch (replyErr) { // Non-fatal: if it fails (e.g. already answered), we still try to kick the loop. @@ -3268,6 +3559,7 @@ export class ChatViewProvider // so SSE events are properly forwarded to the webview. this.processingSessionIds.add(replySessionId); this.activeStreamSessionId = replySessionId; + this.markTurnStreamStarted(replySessionId); this.sendProcessingSessionsUpdate(); this.logger.error("[DEBUG][HOST] Processing state set, kicking agent loop via promptAsync", { replySessionId }); @@ -3338,29 +3630,6 @@ export class ChatViewProvider } break; } - case "persistRawSdkEventPayload": { - const sessionId = this.firstNonEmptyString( - message.sessionId, - this.currentSessionId, - ); - if (!sessionId || typeof message.event === "undefined") { - break; - } - this.logger.info("[CENTRALIZED-TAPE][HOST] persist_raw_sdk_event_requested", { - sessionId, - eventType: typeof (message.event as Record)?.type === "string" - ? (message.event as Record).type - : undefined, - }); - const eventRecord = this.asRecord(message.event); - await this.sessionService.appendRawSdkEventPayload( - sessionId, - eventRecord - ? { ...eventRecord, sessionId } - : message.event, - ); - break; - } case "newSession": case "createSession": { const correlationId = this.logger.startFeatureFlow('create-session'); @@ -3396,9 +3665,8 @@ export class ChatViewProvider type: "chatHistory", sessionId: createdSession.id, messages: [], + sdkMessages: [], rawMessages: [], - rawEventStream: { events: [] }, - subagents: { summariesByParentMessageId: {}, detailsById: {} }, }); // Non-blocking follow-up work: @@ -3928,25 +4196,23 @@ export class ChatViewProvider message.retryWithoutStructuredOutput === true; // Reload chat history to show clean state before retry try { - const sessionHistory = await this.loadCentralizedRenderableHistory( + const sessionHistory = await this.loadSdkRenderableHistory( retrySessionId, ); const messages = sessionHistory.messages; this.logHistoryRenderDiagnostics( "retryLastMessage.reload", retrySessionId, - sessionHistory.events, + [], messages, ); this.view?.webview.postMessage({ type: "chatHistory", sessionId: retrySessionId, messages: messages, + sdkMessages: sessionHistory.sdkMessages, contextInputTokens: sessionHistory.contextInputTokens, - rawEventStream: { events: sessionHistory.events }, - subagents: sessionHistory.subagents, - subagentsRecoveredAfterRestart: sessionHistory.subagentsRecoveredAfterRestart, processingSessionIds: this.getEffectiveProcessingSessionIds(), }); } catch (err) { @@ -4161,6 +4427,27 @@ export class ChatViewProvider rawPropertiesKeys: Object.keys(props), }); } + if (eventType === "permission.asked" || eventType === "permission.request") { + const props = (eventRec?.properties as Record | undefined) || {}; + const sessionId = this.firstNonEmptyString( + props.sessionID, + props.sessionId, + this.activeStreamSessionId, + this.currentSessionId, + ); + this.logger.debug("SDK permission request received", { + sessionId, + permissionId: + typeof props.id === "string" + ? props.id + : typeof props.requestID === "string" + ? props.requestID + : undefined, + permission: typeof props.permission === "string" ? props.permission : undefined, + patternCount: Array.isArray(props.patterns) ? props.patterns.length : 0, + rawPropertiesKeys: Object.keys(props), + }); + } // Always run subagent tracking before any session-scoped early return so child // session events are captured regardless of which session is active in the UI. const subagentUpdate = this.subagentTracker.consumeStreamEvent(event); @@ -4177,17 +4464,6 @@ export class ChatViewProvider ...subagentUpdate, }); this.sendProcessingSessionsUpdate(); - const subagentSessionId = - subagentParentSessionId || this.currentSessionId; - if (subagentSessionId) { - void this.sessionService - .persistCentralizedSubagentProjection(subagentSessionId, subagentUpdate) - .catch((persistError) => { - this.logger.warn("Failed to persist centralized subagent projection", { - err: persistError, - }); - }); - } } // The client-only mirror is only needed for live-only UI events that @@ -4222,23 +4498,6 @@ export class ChatViewProvider }); } - // Persist the raw event before any presentation/session-processing gate. - // Subagent tracker updates intentionally run ahead of those gates; keeping - // persistence here prevents the UI from showing a live subagent that is - // absent from the centralized event stream after an early return below. - const persistenceSessionId = - subagentParentSessionId || - eventSessionId || - this.activeStreamSessionId || - this.currentSessionId; - if (persistenceSessionId) { - this.enqueueRawSdkEventPersistence( - persistenceSessionId, - { ...event, sessionId: persistenceSessionId }, - isTerminalLifecycleEvent, - ); - } - // Sync server-generated session title from session.updated events. // The OpenCode server generates titles using a small model after processing // the first message — we pick up the AI-generated title here. @@ -4299,8 +4558,16 @@ export class ChatViewProvider const shouldBypassProcessingGate = eventType === "question.asked" || + eventType === "permission.asked" || + eventType === "permission.request" || eventType === "message.updated" || + eventType === "message.complete" || eventType === "message.completed" || + eventType === "message.error" || + eventType === "message.aborted" || + eventType === "session.completed" || + eventType === "session.error" || + eventType === "error" || eventType === "session.diff" || eventType === "session.status"; if ( @@ -4442,6 +4709,19 @@ export class ChatViewProvider this.currentSessionId; const info = (properties?.info as Record | undefined) || {}; + const messageId = + typeof info?.id === "string" + ? info.id + : typeof properties?.messageId === "string" + ? properties.messageId + : typeof properties?.messageID === "string" + ? properties.messageID + : undefined; + const isTerminalState = this.isTerminalStreamState(eventType, properties, info); + this.captureStreamedSubtaskPart(resolvedSessionId, properties, part); + if (resolvedSessionId && isTerminalState) { + this.scheduleSdkSubagentChildrenRefresh(resolvedSessionId); + } const preRenderPreview = (typeof part?.delta === "string" && part.delta) || (typeof part?.text === "string" && part.text) || @@ -4457,14 +4737,7 @@ export class ChatViewProvider 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, + messageId, partType: typeof part?.type === "string" ? part.type : undefined, hasStructuredOutput: Boolean((enrichedEvent as any)?.structuredOutput), preview: @@ -4474,24 +4747,16 @@ export class ChatViewProvider }); } - const eventForWebview = this.buildWebviewStreamEvent( - enrichedEvent, - resolvedSessionId, - ); + // Build a detached, truncated webview-bound payload. The persisted SDK + // event (the original reference passed to handleStreamEvent) is never + // mutated — only the webview-bound copy is slimmed. Session ownership + // still travels in the surrounding webview protocol envelope. + const eventForWebview = this.buildWebviewStreamEvent(enrichedEvent || event); const shouldFlushWebviewImmediately = eventType === "question.asked" || - eventType === "message.completed" || - eventType === "session.completed" || - eventType === "session.error" || - eventType === "error" || - eventType === "message.error" || - ( - eventType === "message.updated" && - (() => { - const fin = info?.finish; - return fin === true || (typeof fin === "string" && ["true", "done", "stop", "complete", "completed", "success", "finished", "error"].includes(fin.trim().toLowerCase())); - })() - ) || + eventType === "permission.asked" || + eventType === "permission.request" || + isTerminalState || // Tool/progress activity is the first visible evidence of work. Do not // leave it behind the 50 ms presentation batch while the raw tape has // already been persisted; the webview must be able to replace its @@ -4505,31 +4770,22 @@ export class ChatViewProvider resolvedSessionId, shouldFlushWebviewImmediately, ); - if (resolvedSessionId) { - const centralizedEventPayload = { - ...enrichedEvent, - sessionId: resolvedSessionId, - }; - if (this.shouldVerboseStreamDebug()) { - this.logger.debug("[CENTRALIZED-TAPE][HOST] raw_event_before_store", { - sessionId: resolvedSessionId, - eventType: typeof (centralizedEventPayload as Record)?.type === "string" - ? (centralizedEventPayload as Record).type - : event.type || "unknown", - partType: typeof part?.type === "string" ? part.type : undefined, - rawSource: typeof rawEvent === "object" && rawEvent !== null - ? typeof (rawEvent as Record).source === "string" - ? (rawEvent as Record).source - : undefined - : undefined, - }); - } - this.enqueueRawSdkEventPersistence( - resolvedSessionId, - centralizedEventPayload, - shouldFlushWebviewImmediately, + if ( + eventType === "question.asked" || + eventType === "permission.asked" || + eventType === "permission.request" + ) { + const sessionId = this.firstNonEmptyString( + properties.sessionID, + properties.sessionId, + this.activeStreamSessionId, + this.currentSessionId, ); - } else { + if (sessionId) { + void this.refreshPendingInteractionsFromSdk(sessionId); + } + } + if (!resolvedSessionId) { this.logger.warn("[CENTRALIZED-TAPE][HOST] skipped_raw_event_without_session", { eventType: typeof (enrichedEvent as Record)?.type === "string" ? (enrichedEvent as Record).type @@ -4539,18 +4795,8 @@ export class ChatViewProvider hasRawEvent: typeof rawEvent !== "undefined", }); } - if (resolvedSessionId && ( - isTerminalLifecycleEvent || ( - eventType === "message.updated" && ( - (() => { - const props = (eventRec?.properties as Record | undefined) || {}; - const info = (props?.info as Record | undefined) || {}; - const fin = info?.finish; - return fin === true || (typeof fin === "string" && ["true","done","stop","complete","completed","success","finished","error"].includes(fin.trim().toLowerCase())); - })() - ) - ) - )) { + if (resolvedSessionId && isTerminalState) { + void this.schedulePostTurnSdkRefresh(resolvedSessionId, messageId); this.processingSessionIds.delete(resolvedSessionId); if (this.activeStreamSessionId === resolvedSessionId) { this.activeStreamSessionId = undefined; @@ -4688,7 +4934,6 @@ export class ChatViewProvider } this.flushStreamWebviewEvents(); this.flushLiveEventDebugEvents(); - this.flushRawSdkEventPersistence(); this.isBootstrappingWebview = false; this.hasInitializedWebview = false; this.sessionsListRequestVersion = 0; @@ -4807,83 +5052,51 @@ export class ChatViewProvider return obj; } - private async loadCentralizedRenderableHistory(sessionId: string): Promise<{ - events: unknown[]; - subagents: import("../services/SessionService").CentralizedSubagentProjection; + private async loadSdkRenderableHistory(sessionId: string): Promise<{ + /** False only when the SDK request itself failed; an empty session is available. */ + available: boolean; messages: any[]; + sdkMessages: unknown[]; /** Latest SDK-reported context size for the session's most recent assistant turn. */ contextInputTokens?: number; - subagentsRecoveredAfterRestart: boolean; }> { - this.logger.debug(`[loadCentralizedRenderableHistory] START sessionId=${sessionId}`); + this.logger.debug(`[loadSdkRenderableHistory] START sessionId=${sessionId}`); const start = Date.now(); try { - // Fetch the SDK session messages independently of the renderable event - // tape. The event tape is optimized for UI hydration, while the SDK - // message record's `info.tokens.input` is the authoritative context size. - const contextInputTokens = - await this.sessionService.getLatestContextInputTokens(sessionId); - let rawSessionPayloads = await this.sessionService.loadCentralizedSessionData( - sessionId, - ); - if (!this.recoveredSubagentSessions.has(sessionId)) { - rawSessionPayloads = { - ...rawSessionPayloads, - subagents: await this.sessionService.cancelStaleSubagentsAfterExtensionRestart(sessionId), - }; - this.recoveredSubagentSessions.add(sessionId); - } - const subagentsRecoveredAfterRestart = this.recoveredSubagentSessions.has(sessionId); - - const rawArray = Array.isArray(rawSessionPayloads.rawEventStream.events) - ? rawSessionPayloads.rawEventStream.events - : []; - - this.logger.debug(`[loadCentralizedRenderableHistory] Loaded ${rawArray.length} items from disk in ${Date.now() - start}ms`); - - const t1 = Date.now(); - const safeRawSdkEventPayloads = this.truncateLargeStrings(rawArray); - this.logger.debug(`[loadCentralizedRenderableHistory] Truncated arrays in ${Date.now() - t1}ms`); - - const t2 = Date.now(); - const messages = await this.processHistoryMessages( - safeRawSdkEventPayloads, - sessionId, - ); - const cancelledDetailsById = new Map( - Object.entries(rawSessionPayloads.subagents.detailsById) - .filter(([, detail]) => detail?.status === "cancelled"), - ); - for (const message of messages) { - if (!Array.isArray(message?.subagents)) continue; - message.subagents = message.subagents.map((subagent: any) => { - const cancelledDetail = cancelledDetailsById.get(subagent?.id); - return cancelledDetail ? { ...subagent, ...cancelledDetail } : subagent; - }); - } - this.logger.debug(`[loadCentralizedRenderableHistory] Processed ${messages.length} messages in ${Date.now() - t2}ms`); + // LOCKED CONTRACT — REHYDRATION SOURCE OF TRUTH + // Hydrate directly from the unmodified OpenCode SDK server + // `session.messages()` response. The result is adapted only at the + // webview boundary; never replace this with centralized event + // persistence, SessionService caches, or webview-local data. + const sdkMessages = await this.sessionSnapshotLoader.loadMessagesOnly(sessionId); + const messages = adaptSdkMessages(sdkMessages); + const latestAssistantMessage = [...sdkMessages] + .reverse() + .find((message) => message.info.role === "assistant"); + const contextInputTokens = latestAssistantMessage?.info.role === "assistant" + ? latestAssistantMessage.info.tokens.input + : undefined; - this.logger.debug("[CENTRALIZED-TAPE][HOST] loaded_renderable_history", { + this.logger.debug("[SDK-DEBUG][HOST] loaded_renderable_history", { sessionId, - rawSdkEventCount: safeRawSdkEventPayloads.length, + sdkMessageCount: sdkMessages.length, renderableMessageCount: messages.length, + durationMs: Date.now() - start, }); return { - events: safeRawSdkEventPayloads, - subagents: rawSessionPayloads.subagents, + available: true, messages, + sdkMessages, contextInputTokens, - subagentsRecoveredAfterRestart, }; } catch (err: any) { - this.logger.error(`[loadCentralizedRenderableHistory] ERROR: ${err.message}`, { stack: err.stack }); + this.logger.error(`[loadSdkRenderableHistory] ERROR: ${err.message}`, { stack: err.stack }); return { - events: [], - subagents: { summariesByParentMessageId: {}, detailsById: {} }, + available: false, messages: [], + sdkMessages: [], contextInputTokens: undefined, - subagentsRecoveredAfterRestart: false, }; } } @@ -5133,6 +5346,101 @@ export class ChatViewProvider return mentionsFormat && mentionsUnsupported; } + private structuredFormatValidationError(error: unknown): boolean { + const text = JSON.stringify(error || "").toLowerCase(); + return ( + text.includes("outputformatjsonschema") || + text.includes("schema rejection") || + this.isStructuredFormatUnsupportedError(error) + ); + } + + /** + * Verify the actual server round trip before we persist structured output on + * a user session. This uses `noReply`, so it neither invokes a model nor + * spends tokens. It deliberately uses the same `format` sent by real prompts. + */ + private ensureStructuredOutputFormatCompatibility(client: any): Promise { + if (this.structuredOutputFormatCompatibility) { + return this.structuredOutputFormatCompatibility; + } + + this.structuredOutputFormatCompatibility = (async () => { + const workspaceDirectory = this.getWorkspaceDirectory(); + let probeSessionId: string | undefined; + + try { + const created = await client.session.create({ + ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + title: "OpenCode structured-output compatibility probe", + }); + probeSessionId = this.firstNonEmptyString(created?.data?.id); + if (!probeSessionId || created?.error) { + this.logger.warn("Structured-output compatibility probe could not create a session", { + error: created?.error, + }); + return true; + } + + const prompt = await client.session.prompt({ + sessionID: probeSessionId, + ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + noReply: true, + parts: [{ type: "text", text: "SDK compatibility probe" }], + format: this.getStructuredOutputFormat(), + }); + if (prompt?.error) { + if (this.structuredFormatValidationError(prompt.error)) { + this.logger.warn("OpenCode rejected the structured-output format during prompt validation", { + error: prompt.error, + }); + return false; + } + // Do not disable a working feature for a transient probe failure. + this.logger.warn("Structured-output compatibility probe prompt failed unexpectedly", { + error: prompt.error, + }); + return true; + } + + const messages = await client.session.messages({ + sessionID: probeSessionId, + ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + }); + if (messages?.error && this.structuredFormatValidationError(messages.error)) { + this.logger.warn("OpenCode rejected the persisted structured-output format during session rehydration", { + error: messages.error, + }); + return false; + } + return true; + } catch (error) { + // A probe must never prevent a normal user prompt because the server + // may be reconnecting. Known validator failures are handled above. + this.logger.warn("Structured-output compatibility probe failed unexpectedly", { + error: error instanceof Error ? error.message : String(error), + }); + return true; + } finally { + if (probeSessionId) { + try { + await client.session.delete({ + sessionID: probeSessionId, + ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + }); + } catch (error) { + this.logger.warn("Failed to clean up structured-output compatibility probe session", { + sessionId: probeSessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + })(); + + return this.structuredOutputFormatCompatibility; + } + private parseSlashSkillInvocation(text: string): { name: string; request: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith("/")) { @@ -5332,20 +5640,24 @@ export class ChatViewProvider return callPrompt(body as Record); } - const withSchema = ( - mode: "format" | "outputFormat", - ): Record => ({ + if (!(await this.ensureStructuredOutputFormatCompatibility(client))) { + this.structuredOutputMode = "disabled"; + this.logger.warn( + "OpenCode server rejected structured output during the SDK rehydration probe. Falling back to plain text before sending the user prompt.", + ); + return callPrompt(body as Record); + } + + // `outputFormat` was a legacy, untyped compatibility path. Never send it: + // the SDK server persists this field on the user message and validates it + // again when `session.messages()` rehydrates the session. + const withSchema = (): Record => ({ ...(body as Record), - [mode]: schema, + format: schema, }); - const primaryMode: "format" | "outputFormat" = - this.structuredOutputMode === "outputFormat" - ? "outputFormat" - : "format"; - // Try structured output with 1 retry (handled by API internally via retryCount) - const attempt = await callPrompt(withSchema(primaryMode)); + const attempt = await callPrompt(withSchema()); if (!attempt.error) { return attempt; } @@ -7039,7 +7351,7 @@ export class ChatViewProvider dataUrl: string; filename: string; mimeType: string; - textContent?: string; + textContent: string | undefined; } => !!attachment, ); const imageUrls = normalizedAttachments @@ -7064,6 +7376,7 @@ export class ChatViewProvider drainSessionId = session.id; this.processingSessionIds.add(drainSessionId); + this.markTurnStreamStarted(drainSessionId); this.logger.info("[OPENCOD GO MODEL] Processing started (loading state ON)", { sessionId: drainSessionId, providerID: this.selectedModel.providerID, @@ -7157,6 +7470,25 @@ export class ChatViewProvider filename: attachment.filename, url: attachment.dataUrl, })), + ...(Array.isArray(contexts) ? contexts : []) + .filter( + (ctx) => + typeof ctx?.file === "string" && ctx.file.trim().length > 0, + ) + .map((ctx) => ({ + type: "file" as const, + mime: ctx.languageId || "text/plain", + filename: ctx.file.split(/[\\/]/).pop(), + source: { + type: ctx.file.startsWith("resource:") ? "resource" : "file", + path: ctx.file.startsWith("resource:") ? undefined : ctx.file, + uri: ctx.file.startsWith("resource:") + ? ctx.file.replace("resource:", "") + : undefined, + lineInfo: ctx.lineInfo || undefined, + languageId: ctx.languageId || undefined, + }, + })), ], images: imageUrls, time: { @@ -8203,6 +8535,7 @@ export class ChatViewProvider this.activeStreamSessionId = undefined; } this.sendProcessingSessionsUpdate(); + void this.schedulePostTurnSdkRefresh(resolvedSessionId); if (!options?.suppressWebviewNotification) { this.view?.webview.postMessage({ @@ -8227,9 +8560,23 @@ export class ChatViewProvider // Abort remains best-effort after local finalization. An error here is // diagnostic only; the user has already stopped this turn in the UI. - client.session.abort({ + // The extension uses the SDK v2 convenience client. Its typed + // `session.abort` parameters are flattened as `{ sessionID, directory }` + // and map to POST /session/{sessionID}/abort. + void client.session.abort({ sessionID: resolvedSessionId, ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + }).then((result) => { + if (result.error) { + log.error("Failed to abort active request", { + sessionId: resolvedSessionId, + error: String(result.error), + }); + return; + } + this.logger.info("SDK session abort accepted", { + sessionId: resolvedSessionId, + }); }).catch((error: unknown) => { log.error("Failed to abort active request", { sessionId: resolvedSessionId, @@ -9603,6 +9950,7 @@ export class ChatViewProvider // Surface the early-return case so the user knows why nothing happened // instead of silently failing. if (!targetMessageId || !targetSessionId) { + this.view?.webview.postMessage({ type: "revertStateUpdate", revertState: null }); vscode.window.showWarningMessage( "Unable to undo changes: missing message or session identifier.", ); @@ -9644,6 +9992,7 @@ export class ChatViewProvider vscode.window.showErrorMessage( `Failed to undo changes: ${errorMessage}`, ); + this.view?.webview.postMessage({ type: "revertStateUpdate", revertState: null }); return; } @@ -9686,6 +10035,7 @@ export class ChatViewProvider error: errorMessage, }); vscode.window.showErrorMessage(`Failed to undo changes: ${errorMessage}`); + this.view?.webview.postMessage({ type: "revertStateUpdate", revertState: null }); } } @@ -10195,7 +10545,6 @@ export class ChatViewProvider } this.flushStreamWebviewEvents(); this.flushLiveEventDebugEvents(); - this.flushRawSdkEventPersistence(); this.quotaService.off("quotaUpdate", this.handleQuotaUpdate); if (this.webviewMessageListener) { this.webviewMessageListener.dispose(); diff --git a/src/providers/chat/SessionHandler.ts b/src/providers/chat/SessionHandler.ts index 5208f68..21e6991 100644 --- a/src/providers/chat/SessionHandler.ts +++ b/src/providers/chat/SessionHandler.ts @@ -7,7 +7,9 @@ */ import type { SessionService } from "../../services/SessionService"; -import type { HistoryProcessor } from "./HistoryProcessor"; +import { adaptSdkMessages } from "../../services/SdkMessageAdapter"; +import type { SdkRenderedMessage } from "../../services/SdkMessageAdapter"; +import type { SessionSnapshotLoader } from "../../services/SessionSnapshotLoader"; import type { CompactionManager } from "./CompactionManager"; import type { ModelAndAgentManager } from "./ModelAndAgentManager"; @@ -18,10 +20,10 @@ export class SessionHandler { constructor( private sessionService: SessionService, - private historyProcessor: HistoryProcessor, private compactionManager: CompactionManager, private modelAndAgentManager: ModelAndAgentManager, private logger: ReturnType, + private sessionSnapshotLoader: SessionSnapshotLoader, ) { this.postMessage = () => {}; this.getCurrentSessionId = () => undefined; @@ -155,17 +157,12 @@ export class SessionHandler { // This updates the service's internal state and persists it await this.sessionService.switchSession(sessionId); - const centralizedSessionData = await this.sessionService.loadCentralizedSessionData( - sessionId, - ); - const rawEventStream = centralizedSessionData.rawEventStream; - const subagents = centralizedSessionData.subagents; - const rawMessages = await this.sessionService.getMessages(sessionId); - const fallbackMessages = Array.isArray(rawMessages) ? rawMessages : []; - const messages = await this.historyProcessor.processHistoryMessages( - fallbackMessages, - sessionId, - ); + // SDK inspection mode: session.messages() is the only transcript source. + // It returns the raw `{ info, parts }[]` OpenCode response. The adapter is + // solely a display projection; no centralized event tape or local message + // cache is read as a fallback. + const sdkMessages = await this.sessionSnapshotLoader.loadMessagesOnly(sessionId); + const messages = adaptSdkMessages(sdkMessages); await this.modelAndAgentManager.applySessionSettings(sessionId); @@ -173,8 +170,7 @@ export class SessionHandler { type: "chatHistory", sessionId, messages, - rawEventStream, - subagents, + sdkMessages, }); await this.compactionManager.sendCompactionViewStateForMessages( sessionId, @@ -183,6 +179,7 @@ export class SessionHandler { this.setCurrentSessionId(sessionId); this.logger.info("Session loaded", { sessionId, messageCount: messages.length }); + } catch (error) { this.logger.error("Failed to load session", { sessionId, error }); } finally { @@ -191,6 +188,171 @@ export class SessionHandler { } } + private async enrichSubagentsFromSdkChildren(sessionId: string, messages: any[]): Promise { + try { + const snapshot = await this.sessionSnapshotLoader.loadSnapshot(sessionId); + const childrenById = new Map(); + for (const child of snapshot.children ?? []) { + const childSession = child?.session; + if (typeof childSession?.id === "string" && childSession.id.length > 0) { + childrenById.set(childSession.id, childSession); + } + } + + if (childrenById.size === 0) { + return messages; + } + + const enrichedMessages = messages.map((loadedMessage) => ({ + ...loadedMessage, + subagents: Array.isArray(loadedMessage?.subagents) + ? [...loadedMessage.subagents] + : [], + })); + const messagesById = new Map(); + for (const loadedMessage of enrichedMessages) { + const messageId = this.getMessageId(loadedMessage); + if (messageId) { + messagesById.set(messageId, loadedMessage); + } + } + + for (const sdkMessage of snapshot.messages ?? []) { + const parts = Array.isArray(sdkMessage?.parts) ? sdkMessage.parts : []; + for (const part of parts) { + if (part?.type !== "subtask") { + continue; + } + + const childSessionId = typeof part.sessionID === "string" ? part.sessionID : undefined; + if (!childSessionId) { + continue; + } + + const childSession = childrenById.get(childSessionId); + if (!childSession) { + continue; + } + + const parentMessageId = + (typeof part.messageID === "string" && part.messageID.length > 0 ? part.messageID : undefined) ?? + (typeof sdkMessage?.info?.id === "string" ? sdkMessage.info.id : undefined); + const parentMessage = parentMessageId ? messagesById.get(parentMessageId) : undefined; + if (!parentMessage || !parentMessageId) { + continue; + } + + const detail = this.buildSdkChildSubagentDetail({ + parentSessionId: sessionId, + parentMessageId, + part, + childSession, + }); + this.upsertSubagentDetail(parentMessage, detail); + } + } + + return enrichedMessages; + } catch (error) { + this.logger.warn("SDK subagent enrichment failed", { sessionId, error: String(error) }); + return messages; + } + } + + async loadChildSessionTranscript(childSessionId: string): Promise { + const sdkMessages = await this.sessionSnapshotLoader.loadMessagesOnly(childSessionId); + return adaptSdkMessages(sdkMessages); + } + + private extractSubagentDetails(messages: any[]): any[] { + const details: any[] = []; + for (const message of messages) { + if (Array.isArray(message?.subagents)) { + details.push(...message.subagents); + } + } + return details; + } + + private buildSdkChildSubagentDetail(args: { + parentSessionId: string; + parentMessageId: string; + part: any; + childSession: any; + }): any { + const { parentSessionId, parentMessageId, part, childSession } = args; + const childTime = childSession?.time ?? {}; + const partModel = part?.model ?? {}; + const childModel = childSession?.model ?? {}; + const title = this.optionalString(childSession?.title) + ?? this.optionalString(part?.description) + ?? this.optionalString(part?.prompt) + ?? childSession.id; + + return { + id: childSession.id, + name: title, + title, + parentSessionId, + parentMessageId, + childSessionId: childSession.id, + agentId: this.optionalString(part?.agent), + agent: this.optionalString(part?.agent), + providerID: this.optionalString(childModel?.providerID) ?? this.optionalString(partModel?.providerID), + modelID: this.optionalString(childModel?.modelID) ?? this.optionalString(partModel?.modelID), + model: childSession?.model ?? part?.model, + status: this.deriveChildSessionStatus(childSession), + latestActivity: this.optionalString(childSession?.summary) ?? title, + tokens: childSession?.tokens, + cost: childSession?.cost, + summary: childSession?.summary, + createdAt: this.optionalNumber(childTime?.created), + updatedAt: this.optionalNumber(childTime?.updated), + completedAt: this.optionalNumber(childTime?.completed), + references: [{ messageID: parentMessageId, partID: part?.id }], + thinkingEvents: [], + progressEvents: [], + timelineEvents: [], + }; + } + + private upsertSubagentDetail(message: any, detail: any): void { + const subagents = Array.isArray(message.subagents) ? message.subagents : []; + const existingIndex = subagents.findIndex((existing: any) => { + const existingChildSessionId = existing?.childSessionId ?? existing?.sessionID ?? existing?.id; + return existingChildSessionId === detail.childSessionId; + }); + if (existingIndex >= 0) { + subagents[existingIndex] = { ...subagents[existingIndex], ...detail }; + } else { + subagents.push(detail); + } + message.subagents = subagents; + } + + private getMessageId(message: any): string | undefined { + return this.optionalString(message?.id) ?? this.optionalString(message?.info?.id); + } + + private deriveChildSessionStatus(childSession: any): string { + const time = childSession?.time ?? {}; + if (typeof time.completed === "number") { + return "completed"; + } + if (typeof time.updated === "number" && time.updated > 0) { + return "running"; + } + return "pending"; + } + + private optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; + } + + private optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; + } + /** * Handle delete session request */ diff --git a/src/providers/chat/StreamEventHandler.ts b/src/providers/chat/StreamEventHandler.ts index 0935f81..b87f44b 100644 --- a/src/providers/chat/StreamEventHandler.ts +++ b/src/providers/chat/StreamEventHandler.ts @@ -11,7 +11,6 @@ import type { StructuredOutputProcessor } from "./StructuredOutputProcessor"; import type { CompactionManager } from "./CompactionManager"; import type { DiagnosticsLogger } from "./DiagnosticsLogger"; import type { GeminiTokenUsageTracker } from "../../services/GeminiTokenUsageTracker"; -import type { SessionService } from "../../services/SessionService"; type PendingStreamEvent = { enrichedEvent: any; @@ -37,7 +36,6 @@ export class StreamEventHandler { private compactionManager: CompactionManager, private diagnosticsLogger: DiagnosticsLogger, private geminiTokenTracker: GeminiTokenUsageTracker, - private sessionService: SessionService, private logger: ReturnType, ) { this.postMessage = () => {}; @@ -80,17 +78,11 @@ export class StreamEventHandler { this.compactionManager.forwardCompactionStatusFromStreamEvent(properties.compaction); } - // Handle subagent updates + // Handle subagent updates in the live UI only. Completed child-session + // state is reloaded from the SDK during hydration. const subagentUpdate = this.getSubagentUpdate(properties, enrichedEvent); if (subagentUpdate) { this.logSubagentUpdate(subagentUpdate); - const targetSessionId = sessionId ?? this.getCurrentSessionId(); - if (targetSessionId) { - await this.sessionService.persistCentralizedSubagentProjection( - targetSessionId, - subagentUpdate, - ); - } } // Track token usage @@ -126,7 +118,6 @@ export class StreamEventHandler { ); } - this.persistRawSdkEventPayload(sessionId, event, rawEvent); } private getEventType(event: any): string { @@ -244,22 +235,6 @@ export class StreamEventHandler { ); } - private persistRawSdkEventPayload( - sessionId: string | undefined, - event: any, - rawEvent?: unknown, - ): void { - if (!sessionId) { - return; - } - - const payload = - rawEvent && typeof rawEvent === "object" - ? { ...(rawEvent as Record), sessionId } - : { ...event, sessionId }; - void this.sessionService.appendRawSdkEventPayload(sessionId, payload); - } - private flushPendingEvents(): void { const startedAt = performance.now(); if (this.flushRafId !== null) { @@ -367,8 +342,6 @@ export class StreamEventHandler { success, }); - void this.sessionService.flushRawSdkEventPayloads(sessionId); - // Reset state this.streamStartTime = undefined; this.eventCount = 0; diff --git a/src/providers/chat/StructuredOutputProcessor.ts b/src/providers/chat/StructuredOutputProcessor.ts index b59cc62..5453cd9 100644 --- a/src/providers/chat/StructuredOutputProcessor.ts +++ b/src/providers/chat/StructuredOutputProcessor.ts @@ -8,6 +8,7 @@ */ import * as vscode from "vscode"; +import type { OutputFormatJsonSchema } from "@opencode-ai/sdk/v2"; import type { StructuredAssistantOutput, } from "./types"; @@ -23,7 +24,7 @@ import type { PlanManager } from "./PlanManager"; import { PlanParser } from "../../services/PlanParser"; export class StructuredOutputProcessor { - private structuredOutputMode: "format" | "outputFormat" | "disabled" = "format"; + private structuredOutputMode: "format" | "disabled" = "format"; private readonly structuredValidationFailureCounters = new Map(); private readonly structuredOutputIncompatibleModelKeys = new Set(); @@ -67,7 +68,7 @@ export class StructuredOutputProcessor { /** * Get the structured output format for API requests */ - getStructuredOutputFormat(): Record { + getStructuredOutputFormat(): OutputFormatJsonSchema { const topLevel = structuredOutputSchema as unknown as Record; const schemaRecord = this.asRecord(topLevel.schema); const properties = this.asRecord(schemaRecord?.properties) ?? {}; @@ -107,13 +108,8 @@ export class StructuredOutputProcessor { return true; } - /** - * Get the structured output model key - */ + /** Get the structured output model key. */ getStructuredOutputModelKey(modelKey: string): string { - if (this.structuredOutputMode === "outputFormat") { - return modelKey; - } return modelKey; } diff --git a/src/services/OpencodeServerManager.ts b/src/services/OpencodeServerManager.ts index bd4117a..8d1b5c3 100644 --- a/src/services/OpencodeServerManager.ts +++ b/src/services/OpencodeServerManager.ts @@ -406,7 +406,12 @@ export class OpencodeServerManager { } } - private getWorkspaceDirectory(): string | undefined { + /** + * Current workspace directory used to scope OpenCode SDK requests. + * Session IDs are workspace-scoped by the server, so callers loading a + * session snapshot must pass this value when available. + */ + getWorkspaceDirectory(): string | undefined { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (workspaceFolder && workspaceFolder.uri.scheme === "file") { return workspaceFolder.uri.fsPath.replace(/\\/g, "/").replace(/\/+$/, ""); diff --git a/src/services/SdkMessageAdapter.ts b/src/services/SdkMessageAdapter.ts new file mode 100644 index 0000000..e921e28 --- /dev/null +++ b/src/services/SdkMessageAdapter.ts @@ -0,0 +1,729 @@ +import type { + AgentPart, + AssistantMessage, + CompactionPart, + FilePart, + Message, + Part, + PatchPart, + PermissionRequest, + QuestionRequest, + ReasoningPart, + RetryPart, + Session, + SnapshotPart, + StepFinishPart, + StepStartPart, + SubtaskPart, + TextPart, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk/v2"; + +// --------------------------------------------------------------------------- +// Compatibility types mirroring the webview render model (webview/shared/src/chat/lib/types.ts). +// Defined locally to avoid cross-rootDir imports since the webview lives outside src/. +// --------------------------------------------------------------------------- + +/** Mirrors webview `MessageInfo` — metadata attached to a rendered message. */ +export interface SdkMessageInfo { + id?: string; + parentID?: string; + agent?: string; + role?: string; + model?: { modelID: string; providerID: string; name?: string }; + modelID?: string; + providerID?: string; + variant?: string; + summary?: { title?: string; body?: string }; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + cache?: { read?: number; write?: number }; + }; + duration?: number; + finish?: boolean; + structured?: Record; + structuredOutput?: Record; +} + +/** Mirrors webview `MessagePart` — a single part within a message. */ +export interface SdkMessagePart { + type?: string; + text?: string; + content?: string; + message?: string; + reasoning?: string; + url?: string; + filename?: string; + mime?: string; + source?: unknown; +} + +/** Mirrors webview `MessageStep` — a timeline step / progress item. */ +export interface SdkMessageStep { + type: string; + title: string; + content?: string; + status?: string; + internal?: boolean; + meta?: string; + callID?: string; + id?: string; + messageID?: string; + sessionID?: string; + startedAt?: number; + endedAt?: number; + source?: "final"; + partType?: string; + filePath?: string; + diffStats?: { added: number; deleted: number }; + activityDetail?: { + kind?: string; + tool?: string; + summary?: string; + command?: string; + input?: unknown; + output?: string; + file?: string; + metadata?: Record; + files?: string[]; + diffExcerpt?: { + header?: string; + lines?: string[]; + added?: number; + deleted?: number; + }; + }; +} + +/** Mirrors webview `MessageEdit` — a file change record. */ +export interface SdkMessageEdit { + file: string; + added?: number; + deleted?: number; +} + +/** SDK `info.summary.diffs` projected for the rehydrated File Changes card. */ +export interface SdkMessageChangeSummary { + messageId: string; + filesChanged: number; + added: number; + deleted: number; + files: Array<{ + file: string; + added: number; + deleted: number; + diffExcerpt?: { + header?: string; + lines?: string[]; + added?: number; + deleted?: number; + }; + }>; +} + +/** Mirrors webview `InteractiveEvent` choice option. */ +export interface SdkInteractiveChoice { + id?: string; + label: string; + value?: string; + description?: string; + recommended?: boolean; +} + +/** Mirrors webview `InteractiveQuestionEvent`. */ +export interface SdkInteractiveQuestionEvent { + type: "question"; + id: string; + requestID?: string; + questionIndex?: number; + title?: string; + question: string; + options: SdkInteractiveChoice[]; + multiSelect?: boolean; + allowCustomInput?: boolean; + contextMessage?: string; +} + +/** Typed reasoning projection from the SDK's `ReasoningPart`. */ +export interface SdkReasoningEvent { + id: string; + text: string; + partID: string; + messageID: string; + createdAt: number; + startedAt?: number; + endedAt?: number; +} + +/** Mirrors webview `SubagentDetail` (subset used by the adapter). */ +export interface SdkSubagentDetail { + id: string; + name?: string; + parentSessionId?: string; + parentMessageId?: string; + childSessionId?: string; + agentId?: string; + providerID?: string; + modelID?: string; + status: string; + latestActivity?: string; + references?: Array<{ messageID?: string; partID?: string }>; + thinkingEvents?: unknown[]; + progressEvents?: unknown[]; + timelineEvents?: unknown[]; +} + +/** The rendered message produced by this adapter (mirrors webview `Message`). */ +export interface SdkRenderedMessage { + id?: string; + role?: string; + responseType?: string; + structuredOutput?: Record; + parts?: SdkMessagePart[]; + text?: string; + content?: string; + tokens?: SdkMessageInfo["tokens"]; + duration?: number; + error?: string; + reasoningEvents?: SdkReasoningEvent[]; + progressEvents?: SdkMessageStep[]; + info?: SdkMessageInfo; + plan?: Record; + edits?: SdkMessageEdit[]; + steps?: SdkMessageStep[]; + subagents?: SdkSubagentDetail[]; + interactiveEvents?: SdkInteractiveQuestionEvent[]; + rawStructuredOutputs?: unknown[]; + images?: string[]; + model?: { modelID: string; providerID: string; name?: string }; + modelID?: string; + providerID?: string; + agent?: string; + variant?: string; + created?: number; + summary?: Record; + retryState?: string; + retryStartedAt?: number; + retryMessage?: string; + changeSummary?: SdkMessageChangeSummary; +} + +type SdkMessageEnvelope = { info: Message; parts: Part[] }; +type UnknownRecord = Record; + +function isAssistantMessage(info: Message): info is AssistantMessage { + return info.role === "assistant"; +} + +function isUserMessage(info: Message): info is UserMessage { + return info.role === "user"; +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * The webview renders duration values as seconds. OpenCode message timestamps + * are Unix milliseconds, so normalize once at the SDK boundary. + */ +function durationSeconds(info: AssistantMessage): number | undefined { + const created = optionalNumber(info.time.created); + const completed = optionalNumber(info.time.completed); + if (created === undefined || completed === undefined || completed < created) { + return undefined; + } + return (completed - created) / 1000; +} + +function toRecord(value: unknown): UnknownRecord | undefined { + return isRecord(value) ? value : undefined; +} + +function asDiffStats(value: unknown): { added: number; deleted: number } | undefined { + const record = toRecord(value); + const added = optionalNumber(record?.added) ?? optionalNumber(record?.additions); + const deleted = optionalNumber(record?.deleted) ?? optionalNumber(record?.deletions) ?? optionalNumber(record?.removed); + return added !== undefined || deleted !== undefined + ? { added: added ?? 0, deleted: deleted ?? 0 } + : undefined; +} + +interface ToolDiffProjection { + filePath?: string; + diffStats?: { added: number; deleted: number }; + diffExcerpt?: { + header?: string; + lines?: string[]; + added?: number; + deleted?: number; + }; +} + +function diffExcerptFromPatch( + patch: string | undefined, + diffStats?: { added: number; deleted: number }, +): ToolDiffProjection["diffExcerpt"] { + const lines = patch + ?.replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.replace(/\s+$/u, "")) + .filter((line) => + line.length > 0 && + !line.startsWith("Index: ") && + !line.startsWith("===================================================================") && + !line.startsWith("--- ") && + !line.startsWith("+++ "), + ) + .slice(0, 40); + if (!lines || lines.length === 0) { + return undefined; + } + return { + header: lines.find((line) => line.startsWith("@@")), + lines, + added: diffStats?.added ?? lines.filter((line) => line.startsWith("+")).length, + deleted: diffStats?.deleted ?? lines.filter((line) => line.startsWith("-")).length, + }; +} + +/** + * Converts the SDK's tool-state diff variants into the UI's semantic diff + * fields. The raw SDK part is left untouched; this is only a render adapter. + */ +function toolDiffProjection( + partRecord: UnknownRecord, + stateRecord: UnknownRecord, +): ToolDiffProjection { + const input = toRecord(stateRecord.input); + const metadata = toRecord(stateRecord.metadata); + const fileDiff = toRecord(metadata?.filediff); + const diffStats = + asDiffStats(fileDiff) ?? + asDiffStats(partRecord.diffStats) ?? + asDiffStats(stateRecord.diffStats); + const patch = + optionalString(fileDiff?.patch) ?? + optionalString(metadata?.diff); + const diffExcerpt = diffExcerptFromPatch(patch, diffStats); + + return { + filePath: + optionalString(fileDiff?.file) ?? + optionalString(input?.filePath) ?? + optionalString(partRecord.filePath) ?? + optionalString(stateRecord.filePath), + diffStats, + diffExcerpt, + }; +} + +function summaryDiffProjection(info: Message): SdkMessageChangeSummary | undefined { + const summary = toRecord((info as unknown as UnknownRecord).summary); + const diffs = Array.isArray(summary?.diffs) ? summary.diffs : []; + const files = diffs.flatMap((value) => { + const diff = toRecord(value); + const file = optionalString(diff?.file); + if (!file) { + return []; + } + const diffStats = asDiffStats(diff); + return [{ + file, + added: diffStats?.added ?? 0, + deleted: diffStats?.deleted ?? 0, + diffExcerpt: diffExcerptFromPatch(optionalString(diff?.patch), diffStats), + }]; + }); + if (files.length === 0) { + return undefined; + } + return { + messageId: info.id, + filesChanged: files.length, + added: files.reduce((total, file) => total + file.added, 0), + deleted: files.reduce((total, file) => total + file.deleted, 0), + files, + }; +} + +function modelFromInfo(info: Message): { modelID: string; providerID: string; name?: string } | undefined { + if (isAssistantMessage(info)) { + return { modelID: info.modelID, providerID: info.providerID }; + } + return { modelID: info.model.modelID, providerID: info.model.providerID }; +} + +function buildMessageInfo(info: Message): SdkMessageInfo { + const model = modelFromInfo(info); + const messageInfo: SdkMessageInfo = { + id: info.id, + parentID: isAssistantMessage(info) ? info.parentID : undefined, + role: info.role, + agent: info.agent, + model, + modelID: model?.modelID, + providerID: model?.providerID, + variant: isAssistantMessage(info) ? info.variant : info.model.variant, + }; + + if (isAssistantMessage(info)) { + messageInfo.tokens = info.tokens; + messageInfo.duration = durationSeconds(info); + messageInfo.finish = info.finish !== undefined; + if (info.structured !== undefined) { + messageInfo.structured = toRecord(info.structured) ?? { value: info.structured }; + messageInfo.structuredOutput = adaptStructuredOutput(info); + } + } + + return messageInfo; +} + +function errorToString(error: AssistantMessage["error"]): string | undefined { + if (!error) return undefined; + const data = toRecord(error.data); + return optionalString(data?.message) ?? error.name; +} + +function adaptToolPart(part: ToolPart): SdkMessageStep { + const partRecord = part as ToolPart & UnknownRecord; + const stateRecord = part.state as typeof part.state & UnknownRecord; + const stateTitle = optionalString(stateRecord.title); + const output = optionalString(stateRecord.output) ?? optionalString(stateRecord.error); + const diff = toolDiffProjection(partRecord, stateRecord); + const toolTime = toRecord(stateRecord.time); + + const step: SdkMessageStep = { + type: "tool", + // The tool name drives activity semantics (for example, whether an Edit + // row renders its inline diff). `state.title` is display metadata such as + // "package.json", not the action identity. + title: optionalString(partRecord.title) ?? part.tool ?? stateTitle ?? "tool", + id: part.id, + callID: part.callID, + messageID: part.messageID, + sessionID: part.sessionID, + status: part.state.status, + source: "final", + partType: "tool", + startedAt: typeof toolTime?.start === "number" ? toolTime.start : undefined, + endedAt: typeof toolTime?.end === "number" ? toolTime.end : undefined, + activityDetail: { + kind: "tool_call", + tool: part.tool, + input: part.state.input, + output, + file: diff.filePath, + metadata: toRecord(stateRecord.metadata) ?? undefined, + diffExcerpt: diff.diffExcerpt, + }, + }; + if (diff.diffStats) step.diffStats = diff.diffStats; + if (diff.filePath) step.filePath = diff.filePath; + return step; +} + +function adaptStepStartPart(part: StepStartPart): SdkMessageStep { + const partRecord = part as StepStartPart & UnknownRecord; // title is present in some server payloads before SDK typing catches up. + return { + type: "step-start", + title: optionalString(partRecord.title) ?? "Step started", + internal: true, + meta: optionalString(part.snapshot), + }; +} + +function adaptStepFinishPart(part: StepFinishPart): SdkMessageStep { + const partRecord = part as StepFinishPart & UnknownRecord; + const step: SdkMessageStep = { + type: "step-finish", + title: optionalString(partRecord.title) ?? "Step finished", + status: optionalString(partRecord.status) ?? part.reason, + internal: true, + meta: optionalString(part.snapshot), + }; + const diffStats = asDiffStats(partRecord.diffStats); + if (diffStats) step.diffStats = diffStats; + return step; +} + +function adaptPatchPart(part: PatchPart): SdkMessageEdit[] { + const partRecord = part as PatchPart & UnknownRecord; + const diffStats = asDiffStats(partRecord.diffStats); + const files = Array.isArray(part.files) && part.files.length > 0 + ? part.files + : [optionalString(partRecord.filePath) ?? optionalString(partRecord.file)].filter((file): file is string => !!file); + return files.map((file): SdkMessageEdit => ({ + file, + added: diffStats?.added, + deleted: diffStats?.deleted, + })); +} + +export function adaptSubtaskPart(part: SubtaskPart, info: Message): SdkSubagentDetail { + const id = part.sessionID; + const name = part.description || part.prompt; + const detail: SdkSubagentDetail & { name: string } = { + id, + name, + parentSessionId: info.sessionID, + parentMessageId: part.messageID, + childSessionId: part.sessionID, + agentId: part.agent, + providerID: part.model?.providerID, + modelID: part.model?.modelID, + status: "pending", + latestActivity: name, + references: [{ messageID: part.messageID, partID: part.id }], + thinkingEvents: [], + progressEvents: [], + timelineEvents: [], + }; + return detail; +} + +function adaptFilePart(part: FilePart): SdkMessagePart { + return { + type: "file", + url: part.url, + filename: part.filename, + mime: part.mime, + source: part.source, + }; +} + +function applyRetryMarker(message: SdkRenderedMessage, part: RetryPart): void { + message.retryState = "retrying_without_structured_output"; + message.retryStartedAt = part.time.created; + message.retryMessage = errorToString(part.error) ?? `Retry attempt ${part.attempt}`; +} + +function applyCompactionMarker(message: SdkRenderedMessage, part: CompactionPart): void { + const marker = part.overflow ? "Context compacted after overflow" : part.auto ? "Context compacted automatically" : "Context compacted"; + message.summary = { ...(message.summary ?? {}), body: marker }; + message.info = { ...(message.info ?? {}), summary: { ...(message.info?.summary ?? {}), body: marker } }; +} + +export function adaptSdkMessage(sdkMessage: SdkMessageEnvelope): SdkRenderedMessage { + const { info, parts } = sdkMessage; + const message: SdkRenderedMessage = { + id: info.id, + role: info.role, + info: buildMessageInfo(info), + parts: [], + reasoningEvents: [], + steps: [], + progressEvents: [], + edits: [], + subagents: [], + created: info.time.created, + model: modelFromInfo(info), + modelID: modelFromInfo(info)?.modelID, + providerID: modelFromInfo(info)?.providerID, + agent: info.agent, + changeSummary: summaryDiffProjection(info), + }; + + if (isAssistantMessage(info)) { + message.tokens = info.tokens; + message.duration = durationSeconds(info); + message.error = errorToString(info.error); + if (info.structured !== undefined) { + const structuredOutput = adaptStructuredOutput(info); + message.structuredOutput = structuredOutput; + message.rawStructuredOutputs = [info.structured]; + message.responseType = optionalString(structuredOutput.responseType); + if (toRecord(structuredOutput.plan)) { + message.plan = structuredOutput.plan as Record; + } + const events = structuredOutput.interactiveEvents; + if (Array.isArray(events)) { + message.interactiveEvents = events as SdkInteractiveQuestionEvent[]; + } + } + } + + const textParts: string[] = []; + const images: string[] = []; + + for (const part of parts) { + if (part.type === "text") { + const textPart = part as TextPart; + textParts.push(textPart.text); + message.parts?.push({ type: "text", text: textPart.text }); + } else if (part.type === "reasoning") { + const reasoningPart = part as ReasoningPart; + message.reasoningEvents?.push({ + id: reasoningPart.id, + text: reasoningPart.text, + partID: reasoningPart.id, + messageID: info.id, + createdAt: reasoningPart.time?.start ?? info.time.created, + startedAt: reasoningPart.time?.start, + endedAt: reasoningPart.time?.end, + }); + } else if (part.type === "tool") { + const step = adaptToolPart(part as ToolPart); + message.steps?.push(step); + message.progressEvents?.push(step); + } else if (part.type === "step-start") { + message.steps?.push(adaptStepStartPart(part as StepStartPart)); + } else if (part.type === "step-finish") { + message.steps?.push(adaptStepFinishPart(part as StepFinishPart)); + } else if (part.type === "patch") { + message.edits?.push(...adaptPatchPart(part as PatchPart)); + } else if (part.type === "snapshot") { + const snapshotPart = part as SnapshotPart; + message.steps?.push({ type: "snapshot", title: "Checkpoint", internal: true, meta: snapshotPart.snapshot, id: snapshotPart.id }); + } else if (part.type === "subtask") { + message.subagents?.push(adaptSubtaskPart(part as SubtaskPart, info)); + } else if (part.type === "file") { + const filePart = part as FilePart; + message.parts?.push(adaptFilePart(filePart)); + if (isUserMessage(info) && filePart.mime.startsWith("image/")) { + images.push(filePart.url); + } + } else if (part.type === "agent") { + const agentPart = part as AgentPart; + message.agent = agentPart.name; + message.info = { ...(message.info ?? {}), agent: agentPart.name }; + } else if (part.type === "retry") { + applyRetryMarker(message, part as RetryPart); + } else if (part.type === "compaction") { + applyCompactionMarker(message, part as CompactionPart); + } + } + + const content = textParts.join(""); + if (content) { + message.content = content; + message.text = content; + } + if (images.length > 0) { + message.images = images; + } + + return message; +} + +export function adaptSdkMessages(sdkMessages: SdkMessageEnvelope[]): SdkRenderedMessage[] { + // `info.summary.diffs` belongs to the SDK message that owns it (normally + // the user request). Keep that ownership intact: the transcript can then + // place the change summary directly below that request, while its + // `messageId` remains the exact Undo target. Moving it to a neighbouring + // assistant envelope loses both the visual ordering and the SDK boundary. + return sdkMessages.map(adaptSdkMessage); +} + +export function adaptStructuredOutput(info: AssistantMessage): Record { + const structured = toRecord(info.structured); + if (!structured) return {}; + + const responseType = optionalString(structured.type) + ?? optionalString(structured.responseType) + ?? (structured.plan !== undefined ? "implementation_plan" : structured.question !== undefined ? "question" : "message"); + const result: Record = { + ...structured, + type: responseType, + responseType, + }; + + const plan = toRecord(structured.plan); + if (plan) { + const files = Array.isArray(plan.files) ? plan.files : undefined; + result.plan = { + file: optionalString(plan.file), + content: optionalString(plan.content), + title: optionalString(plan.title), + intro: optionalString(plan.intro), + summary: optionalString(plan.summary), + files, + fileCount: optionalNumber(plan.fileCount) ?? files?.length, + }; + } + + const question = toRecord(structured.question); + if (question) { + const event = questionRecordToInteractiveEvent(question, optionalString(structured.requestID) ?? info.id); + if (event) result.interactiveEvents = [event]; + } + + return result; +} + +export function adaptSessionToMeta(session: Session): { id: string; title: string; createdAt?: number; parentSessionId?: string } { + return { + id: session.id, + title: session.title, + createdAt: session.time?.created, + parentSessionId: session.parentID, + }; +} + +function questionRecordToInteractiveEvent(question: UnknownRecord, requestID: string, questionIndex = 0): SdkInteractiveQuestionEvent | undefined { + const questionText = optionalString(question.question) ?? optionalString(question.text) ?? optionalString(question.message); + if (!questionText) return undefined; + const rawOptions = Array.isArray(question.options) ? question.options : []; + return { + type: "question", + id: `${requestID}:${questionIndex}`, + requestID, + questionIndex, + title: optionalString(question.header) ?? optionalString(question.title), + question: questionText, + options: rawOptions.map((option, index) => { + const optionRecord = toRecord(option); + const label = optionalString(optionRecord?.label) ?? optionalString(option) ?? `Option ${index + 1}`; + return { + id: optionalString(optionRecord?.id) ?? String(index), + label, + value: optionalString(optionRecord?.value) ?? label, + description: optionalString(optionRecord?.description), + recommended: optionRecord?.recommended === true, + }; + }), + multiSelect: question.multiple === true || question.multiSelect === true, + allowCustomInput: question.custom === true || question.allowCustomInput === true, + contextMessage: optionalString(question.displayPrompt) ?? optionalString(question.contextMessage), + }; +} + +export function adaptQuestionRequest(q: QuestionRequest): SdkInteractiveQuestionEvent { + const firstQuestion = toRecord(q.questions[0]); + return (firstQuestion ? questionRecordToInteractiveEvent(firstQuestion, q.id, 0) : undefined) ?? { + type: "question", + id: `${q.id}:0`, + requestID: q.id, + questionIndex: 0, + question: "Question", + options: [], + }; +} + +export function adaptPermissionRequest(p: PermissionRequest): { + id: string; + sessionID: string; + permission: string; + patterns: string[]; + tool?: { messageID: string; callID: string }; +} { + return { + id: p.id, + sessionID: p.sessionID, + permission: p.permission, + patterns: p.patterns, + tool: p.tool, + }; +} diff --git a/src/services/SessionService.ts b/src/services/SessionService.ts index 671ae95..346872f 100644 --- a/src/services/SessionService.ts +++ b/src/services/SessionService.ts @@ -55,14 +55,6 @@ import type { Session } from "@opencode-ai/sdk/v2"; import { createLogger } from "../utils/Logger"; import { LoggingCategories } from "../utils/LoggingSchema"; import { restoreCheckpointIfPresent } from "./CheckpointRestore"; -import { - getCentralizedDebugPayloadIdentity, - sanitizeCentralizedDebugPayload, - shouldPersistCentralizedSessionEventPayload, -} from "../shared/centralizedDebugPayloadFilter"; -import { createPlainObjectSnapshot } from "../shared/createPlainObjectSnapshot"; -import type { SubagentUpdatePayload } from "./SubagentTracker"; -import { recoverSubagentProjectionAfterRestart } from "./subagents/SubagentProjectionRecovery"; const log = createLogger(LoggingCategories.SESSION_SERVICE); const MAX_CACHED_MESSAGES_PER_SESSION = 200; @@ -78,23 +70,6 @@ const MAX_COMPACT_SUBAGENTS = 64; const MAX_COMPACT_SUBAGENT_EVENTS = 120; const MAX_COMPACT_INTERACTIVE_EVENTS = 40; -/** - * Persisted, normalized projection for the subagent UI. The event stream - * remains the authoritative record; this projection is a bounded cache used - * for fast, deterministic session hydration. - */ -export type CentralizedSubagentProjection = Pick< - SubagentUpdatePayload, - "summariesByParentMessageId" | "detailsById" ->; - -export type CentralizedSessionData = { - schemaVersion: 1; - sessionId: string; - rawEventStream: { events: unknown[] }; - subagents: CentralizedSubagentProjection; -}; - function isDataUrl(value: string): boolean { return /^data:[^;]+;base64,/i.test(value); } @@ -178,16 +153,6 @@ function sanitizeForPersistence( seen, ); } - if ( - !Object.prototype.hasOwnProperty.call(result, "rawSdkEventPayloads") && - Object.prototype.hasOwnProperty.call(obj, "rawSdkEventPayloads") - ) { - result.rawSdkEventPayloads = sanitizeForPersistence( - obj.rawSdkEventPayloads, - depth + 1, - seen, - ); - } return result; } @@ -263,73 +228,6 @@ function compactProgressEventForPersistence(event: unknown): unknown { return compact; } -function rawSdkEventPersistenceRichness(event: unknown): number { - const rec = - event && typeof event === "object" - ? (event as Record) - : null; - if (!rec) { - return 0; - } - - const properties = - rec.properties && typeof rec.properties === "object" - ? (rec.properties as Record) - : null; - const info = - (properties?.info && typeof properties.info === "object" - ? (properties.info as Record) - : null) ?? - (rec.info && typeof rec.info === "object" - ? (rec.info as Record) - : null); - const part = - (properties?.part && typeof properties.part === "object" - ? (properties.part as Record) - : null) ?? - (rec.part && typeof rec.part === "object" - ? (rec.part as Record) - : null); - const state = - part?.state && typeof part.state === "object" - ? (part.state as Record) - : null; - - let score = 0; - if (typeof rec.id === "string" && rec.id.trim()) score += 2; - if (typeof rec.type === "string" && rec.type.trim()) score += 2; - if (part) { - score += 6; - if (typeof part.id === "string" && part.id.trim()) score += 2; - if (typeof part.type === "string" && part.type.trim()) score += 2; - if (typeof part.tool === "string" && part.tool.trim()) score += 2; - if (typeof part.messageID === "string" && part.messageID.trim()) score += 2; - if (typeof part.text === "string" && part.text.trim()) score += 3; - if (typeof part.content === "string" && part.content.trim()) score += 3; - } - if (state) { - score += 8; - if (typeof state.status === "string" && state.status.trim()) score += 2; - if (typeof state.input !== "undefined") score += 6; - if (typeof state.output !== "undefined") score += 4; - if (typeof state.metadata !== "undefined") score += 2; - if (typeof state.title === "string" && state.title.trim()) score += 1; - } - if (info) { - score += 4; - if (typeof info.id === "string" && info.id.trim()) score += 2; - if (typeof info.role === "string" && info.role.trim()) score += 1; - } - - try { - score += JSON.stringify(rec).length; - } catch { - // Ignore serialization issues; structural richness already covers the fallback. - } - - return score; -} - function compactSubagentForPersistence(subagent: unknown): unknown { if (!subagent || typeof subagent !== "object") { return sanitizeForPersistence(subagent); @@ -442,13 +340,6 @@ 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[]) @@ -1009,7 +900,6 @@ function mergeRicherMessageFields( backfillArrayField("steps"); backfillArrayField("edits"); backfillArrayField("interactiveEvents"); - backfillArrayField("rawSdkEventPayloads"); backfillArrayField("parts"); backfillArrayField("subagents", true); backfillArrayField("images"); @@ -1024,9 +914,6 @@ function mergeRicherMessageFields( 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; } @@ -1274,215 +1161,6 @@ export class SessionService { /** 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(); - - /** Normalized subagent projection persisted beside the event stream. */ - private centralizedSubagentProjectionCache = new Map< - string, - CentralizedSubagentProjection - >(); - - /** Serializes envelope writes so raw-event and projection updates cannot race. */ - private centralizedSessionWriteChains = new Map>(); - - /** Per-session debounce timer for raw SDK event payload persistence */ - private rawSdkEventPersistTimers = new Map>(); - - private cloneRawSdkEventPayload(value: T): T { - return createPlainObjectSnapshot(value); - } - - private emptySubagentProjection(): CentralizedSubagentProjection { - return { summariesByParentMessageId: {}, detailsById: {} }; - } - - private normalizeSubagentProjection( - value: unknown, - ): CentralizedSubagentProjection { - const record = - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; - const summaries = - record.summariesByParentMessageId && - typeof record.summariesByParentMessageId === "object" && - !Array.isArray(record.summariesByParentMessageId) - ? record.summariesByParentMessageId - : {}; - const details = - record.detailsById && - typeof record.detailsById === "object" && - !Array.isArray(record.detailsById) - ? record.detailsById - : {}; - return { - summariesByParentMessageId: sanitizeForPersistence( - summaries, - ) as CentralizedSubagentProjection["summariesByParentMessageId"], - detailsById: sanitizeForPersistence( - details, - ) as CentralizedSubagentProjection["detailsById"], - }; - } - - /** Keep a session envelope from retaining another session's subagent state. */ - private scopeSubagentProjectionToSession( - sessionId: string, - projection: CentralizedSubagentProjection, - ): CentralizedSubagentProjection { - const summariesByParentMessageId: CentralizedSubagentProjection["summariesByParentMessageId"] = {}; - for (const [parentMessageId, summaries] of Object.entries( - projection.summariesByParentMessageId || {}, - )) { - const scoped = (Array.isArray(summaries) ? summaries : []).filter( - (summary) => summary?.parentSessionId === sessionId, - ); - if (scoped.length > 0) { - summariesByParentMessageId[parentMessageId] = scoped; - } - } - - const detailsById: CentralizedSubagentProjection["detailsById"] = {}; - for (const [id, detail] of Object.entries(projection.detailsById || {})) { - if (detail?.parentSessionId === sessionId) { - detailsById[id] = detail; - } - } - return { summariesByParentMessageId, detailsById }; - } - - private readCentralizedSessionData(sessionId: string): CentralizedSessionData { - const stored = this.context.workspaceState.get( - `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, - ); - // Legacy storage was a bare event array. Read it without data loss and - // rewrite it in the versioned envelope on the next persistence operation. - if (Array.isArray(stored)) { - return { - schemaVersion: 1, - sessionId, - rawEventStream: { events: stored }, - subagents: this.emptySubagentProjection(), - }; - } - const record = - stored && typeof stored === "object" && !Array.isArray(stored) - ? (stored as Record) - : {}; - const rawEventStream = - record.rawEventStream && - typeof record.rawEventStream === "object" && - !Array.isArray(record.rawEventStream) - ? (record.rawEventStream as Record) - : {}; - return { - schemaVersion: 1, - sessionId, - rawEventStream: { - events: Array.isArray(rawEventStream.events) ? rawEventStream.events : [], - }, - subagents: this.scopeSubagentProjectionToSession( - sessionId, - this.normalizeSubagentProjection(record.subagents), - ), - }; - } - - private async saveCentralizedSessionData( - data: CentralizedSessionData, - ): Promise { - this.rawSdkEventPayloadCache.set(data.sessionId, [...data.rawEventStream.events]); - this.centralizedSubagentProjectionCache.set(data.sessionId, data.subagents); - await this.context.workspaceState.update( - `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${data.sessionId}`, - data, - ); - await this.context.workspaceState.update( - `${SessionService.LEGACY_SUBAGENT_SNAPSHOT_PREFIX}${data.sessionId}`, - undefined, - ); - } - - private async updateCentralizedSessionData( - sessionId: string, - update: (current: CentralizedSessionData) => CentralizedSessionData, - ): Promise { - const previous = this.centralizedSessionWriteChains.get(sessionId) || Promise.resolve(); - const operation = previous - .catch(() => undefined) - .then(async () => { - await this.saveCentralizedSessionData( - update(this.readCentralizedSessionData(sessionId)), - ); - }); - this.centralizedSessionWriteChains.set(sessionId, operation); - try { - await operation; - } finally { - if (this.centralizedSessionWriteChains.get(sessionId) === operation) { - this.centralizedSessionWriteChains.delete(sessionId); - } - } - } - - private shouldPersistRawSdkEventPayload(event: unknown): boolean { - return shouldPersistCentralizedSessionEventPayload(event); - } - - private describeRawSdkEventPayloadForLog(event: unknown): Record { - const rec = - event && typeof event === "object" && !Array.isArray(event) - ? (event as Record) - : undefined; - const properties = - rec?.properties && typeof rec.properties === "object" && !Array.isArray(rec.properties) - ? (rec.properties as Record) - : undefined; - const info = - properties?.info && typeof properties.info === "object" && !Array.isArray(properties.info) - ? (properties.info as Record) - : undefined; - const part = - properties?.part && typeof properties.part === "object" && !Array.isArray(properties.part) - ? (properties.part as Record) - : undefined; - - return { - eventType: typeof rec?.type === "string" ? rec.type : undefined, - source: typeof rec?.source === "string" ? rec.source : undefined, - sessionId: - typeof rec?.sessionId === "string" - ? rec.sessionId - : typeof rec?.sessionID === "string" - ? rec.sessionID - : typeof properties?.sessionId === "string" - ? properties.sessionId - : typeof properties?.sessionID === "string" - ? properties.sessionID - : undefined, - messageId: - typeof info?.id === "string" - ? info.id - : typeof part?.messageId === "string" - ? part.messageId - : typeof part?.messageID === "string" - ? part.messageID - : undefined, - partType: typeof part?.type === "string" ? part.type : undefined, - hasProperties: !!properties, - hasInfo: !!info, - hasPart: !!part, - }; - } - - private filterPersistedRawSdkEventPayloads(events: unknown[] | undefined): unknown[] { - if (!Array.isArray(events) || events.length === 0) { - return []; - } - return events.filter((event) => this.shouldPersistRawSdkEventPayload(event)); - } - // ============================================================================ // PERSISTENCE KEYS // ============================================================================ @@ -1495,14 +1173,6 @@ 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 event payloads per session (appended with session ID) */ - private static readonly RAW_SDK_EVENT_PAYLOADS_PREFIX = - "opencode.session.raw-sdk-event-payloads."; - - /** Retired independent snapshot; cleared after centralized writes. */ - private static readonly LEGACY_SUBAGENT_SNAPSHOT_PREFIX = - "opencode.session.subagents."; - /** Key for storing current session ID */ private static readonly SESSION_ID_KEY = "opencode.currentSessionId"; @@ -1818,11 +1488,7 @@ export class SessionService { 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(); + const client = await this.serverManager.ensureRunning(); log.featureStep(flow, 'fetching_session_from_server'); const response = await client.session.get({ @@ -1919,11 +1585,6 @@ export class SessionService { log.debug("Cleared current session (was deleted)", { sessionId }); } - await this.flushRawSdkEventPayloads(sessionId); - // Memory fix: evict in-memory caches for the deleted session so data - // doesn't accumulate indefinitely for sessions that no longer exist. - this.rawSdkEventPayloadCache.delete(sessionId); - this.centralizedSubagentProjectionCache.delete(sessionId); this.sessionHistory = this.sessionHistory.filter((s) => s.id !== sessionId); await this.context.workspaceState.update( `${SessionService.MESSAGES_PREFIX}${sessionId}`, @@ -1933,14 +1594,6 @@ export class SessionService { `opencode.session.raw-messages.${sessionId}`, undefined, ); - await this.context.workspaceState.update( - `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`, - undefined, - ); - await this.context.workspaceState.update( - `${SessionService.LEGACY_SUBAGENT_SNAPSHOT_PREFIX}${sessionId}`, - undefined, - ); this.persistState(); log.sessionEvent("delete", sessionId, { @@ -2289,122 +1942,6 @@ export class SessionService { return Array.isArray(value) ? value : []; } - /** - * 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 = events.map((event) => - this.cloneRawSdkEventPayload(event), - ); - const existingTimer = this.rawSdkEventPersistTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - this.rawSdkEventPersistTimers.delete(sessionId); - } - await this.updateCentralizedSessionData(sessionId, (current) => ({ - ...current, - rawEventStream: { events: 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)) { - log.debug("[CENTRALIZED-TAPE][SESSION] load_raw_sdk_events_cache_hit", { - sessionId, - count: cached.length, - }); - return [...cached]; - } - const centralized = this.readCentralizedSessionData(sessionId); - const raw = centralized.rawEventStream.events; - this.rawSdkEventPayloadCache.set(sessionId, [...raw]); - this.centralizedSubagentProjectionCache.set(sessionId, centralized.subagents); - log.debug("[CENTRALIZED-TAPE][SESSION] load_raw_sdk_events_workspace_state", { - sessionId, - count: raw.length, - hadStoredArray: raw.length > 0, - }); - return raw; - } - - /** - * Loads centralized session event data for a session. - * - * Called from ChatViewProvider and SessionHandler when rehydrating a session - * into the webview chat UI (chatHistory message). - * - * @param sessionId - The ID of the session to load data for - * @returns Object containing centralized raw SDK event payloads - */ - async loadCentralizedSessionData(sessionId: string): Promise { - void this.context.workspaceState.update( - `opencode.session.raw-messages.${sessionId}`, - undefined, - ); - const events = await this.loadSessionRawSdkEventPayloads(sessionId); - const subagents = - this.centralizedSubagentProjectionCache.get(sessionId) || - this.emptySubagentProjection(); - return { - schemaVersion: 1, - sessionId, - rawEventStream: { events }, - subagents, - }; - } - - /** - * Finalize subagents left live by a previous extension-host instance. - * - * The tracker itself is in-memory. A persisted pending/running projection - * cannot be live after activation, so make that terminal state durable - * before replaying the session into the webview. - */ - async cancelStaleSubagentsAfterExtensionRestart( - sessionId: string, - ): Promise { - await this.updateCentralizedSessionData(sessionId, (current) => ({ - ...current, - subagents: recoverSubagentProjectionAfterRestart(current.subagents), - })); - - return this.centralizedSubagentProjectionCache.get(sessionId) || this.emptySubagentProjection(); - } - - /** Merge a live subagent update into the centralized session payload. */ - async persistCentralizedSubagentProjection( - sessionId: string, - update: SubagentUpdatePayload, - ): Promise { - const incoming = this.scopeSubagentProjectionToSession( - sessionId, - this.normalizeSubagentProjection(update), - ); - await this.updateCentralizedSessionData(sessionId, (current) => ({ - ...current, - subagents: { - summariesByParentMessageId: { - ...current.subagents.summariesByParentMessageId, - ...incoming.summariesByParentMessageId, - }, - detailsById: { - ...current.subagents.detailsById, - ...incoming.detailsById, - }, - }, - })); - } - /** * Appends a new message to the local message history for a session. @@ -2472,224 +2009,9 @@ export class SessionService { await this.saveSessionMessages(sessionId, messages); } - private truncateLargeStrings(obj: any, maxLen: number = 200000): any { - if (!obj || typeof obj !== "object") return obj; - const seen = new WeakSet(); - const stack = [obj]; - - while (stack.length > 0) { - const current = stack.pop(); - if (!current || typeof current !== "object" || seen.has(current)) continue; - seen.add(current); - - if (Array.isArray(current)) { - for (let i = 0; i < current.length; i++) { - const item = current[i]; - if (typeof item === "string") { - if (item.length > maxLen) { - current[i] = item.slice(0, maxLen) + "\n...[truncated " + (item.length - maxLen) + " chars]"; - } - } else if (item && typeof item === "object") { - stack.push(item); - } - } - } else { - for (const key in current) { - if (Object.prototype.hasOwnProperty.call(current, key)) { - const item = current[key]; - if (typeof item === "string") { - if (item.length > maxLen) { - current[key] = item.slice(0, maxLen) + "\n...[truncated " + (item.length - maxLen) + " chars]"; - } - } else if (item && typeof item === "object") { - stack.push(item); - } - } - } - } - } - return obj; - } - - async appendRawSdkEventPayload(sessionId: string, rawEvent: unknown): Promise { - await this.appendRawSdkEventPayloads(sessionId, [rawEvent]); - } - - async appendRawSdkEventPayloads(sessionId: string, rawEvents: unknown[]): Promise { - if (!Array.isArray(rawEvents) || rawEvents.length === 0) { - return; - } - - log.debug("[CENTRALIZED-TAPE][SESSION] append_batch_received", { - sessionId, - count: rawEvents.length, - }); - - let events = this.rawSdkEventPayloadCache.get(sessionId); - let loadedFromState = false; - if (!Array.isArray(events)) { - const centralized = this.readCentralizedSessionData(sessionId); - events = [...centralized.rawEventStream.events]; - this.centralizedSubagentProjectionCache.set( - sessionId, - centralized.subagents, - ); - this.rawSdkEventPayloadCache.set(sessionId, events); - loadedFromState = true; - log.debug("[CENTRALIZED-TAPE][SESSION] append_loaded_existing_events", { - sessionId, - loadedFromState, - existingCount: events.length, - hadStoredArray: events.length > 0, - }); - } - - const identityIndex = new Map(); - for (let index = 0; index < events.length; index += 1) { - const identity = getCentralizedDebugPayloadIdentity(events[index]); - if (identity) { - identityIndex.set(identity, index); - } - } - - let appended = 0; - let replaced = 0; - let duplicateSkipped = 0; - let rejected = 0; - - for (const rawEvent of rawEvents) { - const event = this.truncateLargeStrings(this.cloneRawSdkEventPayload(rawEvent)); - const eventSummary = this.describeRawSdkEventPayloadForLog(event); - if (!this.shouldPersistRawSdkEventPayload(event)) { - rejected += 1; - log.warn("[CENTRALIZED-TAPE][SESSION] append_rejected_by_filter", { - sessionId, - ...eventSummary, - }); - continue; - } - - const sanitizedEvent = sanitizeCentralizedDebugPayload(event); - const snapshot = this.cloneRawSdkEventPayload(sanitizedEvent); - const eventIdentity = getCentralizedDebugPayloadIdentity(event); - if (eventIdentity) { - const existingIndex = identityIndex.get(eventIdentity); - if (typeof existingIndex === "number") { - const existingEvent = events[existingIndex]; - if ( - rawSdkEventPersistenceRichness(snapshot) >= - rawSdkEventPersistenceRichness(existingEvent) - ) { - events[existingIndex] = snapshot; - replaced += 1; - } else { - duplicateSkipped += 1; - } - } else { - identityIndex.set(eventIdentity, events.length); - events.push(snapshot); - appended += 1; - } - } else { - events.push(snapshot); - appended += 1; - } - } - - log.debug("[CENTRALIZED-TAPE][SESSION] append_batch_result", { - sessionId, - appended, - replaced, - duplicateSkipped, - rejected, - currentEventsLength: events.length, - loadedFromState, - }); - - const existingTimer = this.rawSdkEventPersistTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - log.debug("[CENTRALIZED-TAPE][SESSION] append_reset_flush_timer", { - sessionId, - currentEventsLength: events.length, - }); - } - - this.rawSdkEventPersistTimers.set( - sessionId, - setTimeout(() => { - void this.flushRawSdkEventPayloads(sessionId); - this.rawSdkEventPersistTimers.delete(sessionId); - }, 250), - ); - log.debug("[CENTRALIZED-TAPE][SESSION] append_scheduled_flush", { - sessionId, - delayMs: 250, - currentEventsLength: events.length, - }); - } - - async flushRawSdkEventPayloads(sessionId: string): Promise { - const events = this.rawSdkEventPayloadCache.get(sessionId); - if (!Array.isArray(events)) { - log.warn("[CENTRALIZED-TAPE][SESSION] flush_skipped_no_cache", { - sessionId, - }); - return; - } - const existingTimer = this.rawSdkEventPersistTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - this.rawSdkEventPersistTimers.delete(sessionId); - } - const storageKey = `${SessionService.RAW_SDK_EVENT_PAYLOADS_PREFIX}${sessionId}`; - log.info("[CENTRALIZED-TAPE][SESSION] flush_start", { - sessionId, - storageKey, - flushedEventsLength: events.length - }); - try { - await this.updateCentralizedSessionData(sessionId, (current) => ({ - ...current, - rawEventStream: { events: [...events] }, - })); - const stored = this.readCentralizedSessionData(sessionId); - log.info("[CENTRALIZED-TAPE][SESSION] flush_complete", { - sessionId, - storageKey, - flushedEventsLength: events.length, - storedEventsLength: stored.rawEventStream.events.length, - storedIsArray: false, - }); - } catch (error) { - log.error("[CENTRALIZED-TAPE][SESSION] flush_failed", { - sessionId, - storageKey, - flushedEventsLength: events.length, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } - } - - async flushAllRawSdkEventPayloads(): Promise { - const sessionIds = Array.from(this.rawSdkEventPayloadCache.keys()); - for (const sessionId of sessionIds) { - await this.flushRawSdkEventPayloads(sessionId); - } - } - async dispose(): Promise { - // Flush all pending event payloads to workspaceState before clearing timers - await this.flushAllRawSdkEventPayloads(); - for (const timer of this.rawSdkEventPersistTimers.values()) { - clearTimeout(timer); - } - this.rawSdkEventPersistTimers.clear(); - // Memory fix: release in-memory caches — data has been flushed to disk - // above and the service is being torn down. - this.rawSdkEventPayloadCache.clear(); - this.centralizedSubagentProjectionCache.clear(); + // Session history is authoritative in OpenCode; no local event tape needs + // flushing or disposal. } private async mergeMessagesForSessionAliases( diff --git a/src/services/SessionSnapshotLoader.ts b/src/services/SessionSnapshotLoader.ts new file mode 100644 index 0000000..3323b36 --- /dev/null +++ b/src/services/SessionSnapshotLoader.ts @@ -0,0 +1,206 @@ +import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; +import type { QuestionRequest } from "@opencode-ai/sdk/v2"; +import type { PermissionRequest } from "@opencode-ai/sdk/v2"; +import { OpencodeServerManager } from "./OpencodeServerManager"; +import { createLogger } from "../utils/Logger"; +import { LoggingCategories } from "../utils/LoggingSchema"; + +export type SessionSnapshot = { + session: Session; + messages: Array<{ info: Message; parts: Part[] }>; + pendingQuestions: QuestionRequest[]; + pendingPermissions: PermissionRequest[]; + children: Array<{ session: Session }>; +}; + +type SessionMessages = Array<{ info: Message; parts: Part[] }>; + +type SdkResponse = { + data?: T; + error?: unknown; + response: { status: number }; +}; + +const log = createLogger(LoggingCategories.SESSION_SERVICE); + +export class SessionSnapshotLoader { + constructor(private readonly serverManager: OpencodeServerManager) {} + + async loadSnapshot(sessionID: string): Promise { + log.info("[SDK-DEBUG] loadSnapshot START", { sessionId: sessionID }); + + const client = await this.serverManager.ensureRunning(); + const directory = this.serverManager.getWorkspaceDirectory(); + const sessionScope = directory ? { directory } : {}; + + const results = await Promise.all([ + this.loadEndpoint("session.get", sessionID, () => + client.session.get({ sessionID, ...sessionScope }) as Promise>, + ), + this.loadEndpoint("session.messages", sessionID, () => + client.session.messages({ sessionID, ...sessionScope }) as Promise>, + ), + this.loadEndpoint("question.list", sessionID, () => + client.question.list() as Promise>, + ), + this.loadEndpoint("permission.list", sessionID, () => + client.permission.list() as Promise>, + ), + this.loadEndpoint("session.children", sessionID, () => + client.session.children({ sessionID, ...sessionScope }) as Promise>, + ), + ]); + + const [session, messages, questions, permissions, children] = results; + + const endpointStatuses = { + session: !!session, + messages: Array.isArray(messages) ? messages.length : -1, + questions: Array.isArray(questions) ? questions.length : -1, + permissions: Array.isArray(permissions) ? permissions.length : -1, + children: Array.isArray(children) ? children.length : -1, + }; + log.info("[SDK-DEBUG] loadSnapshot DONE", { sessionId: sessionID, ...endpointStatuses }); + + if (!session) { + throw new Error(`Failed to load session metadata for ${sessionID}`); + } + + const sdkMessages = messages ?? []; + if (sdkMessages.length > 0) { + const sample = sdkMessages[0]; + const sampleKeys = Object.keys(sample); + const partTypes = sdkMessages.slice(0, 3).map((m) => + Array.isArray(m.parts) ? m.parts.map((p: Part) => p.type) : [], + ); + log.info("[SDK-DEBUG] session.messages response shape", { + sessionId: sessionID, + messageCount: sdkMessages.length, + sampleKeys, + sampleRole: (sample.info as { role?: string }).role, + samplePartTypes: partTypes, + hasParts: sdkMessages.every((m) => Array.isArray(m.parts)), + }); + } + + return { + session, + messages: messages ?? [], + pendingQuestions: (questions ?? []).filter( + (question) => question.sessionID === sessionID, + ), + pendingPermissions: (permissions ?? []).filter( + (permission) => permission.sessionID === sessionID, + ), + children: (children ?? []).map((childSession) => ({ session: childSession })), + }; + } + + async loadMessagesOnly(sessionID: string): Promise { + const client = await this.serverManager.ensureRunning(); + const directory = this.serverManager.getWorkspaceDirectory(); + // LOCKED CONTRACT — REHYDRATION SOURCE OF TRUTH + // This must call the OpenCode SDK server for every hydration. Do not read + // workspaceState, SessionService caches, persisted event tapes, or a + // webview-owned snapshot as a fallback. `session.messages()` is the only + // authoritative source for rehydratedSdkMessages and rendered history. + // Do not use the tolerant multi-endpoint loader here. This is the single + // authoritative transcript source, and collapsing a failed SDK request + // into `[]` makes a transport error indistinguishable from a genuinely + // empty OpenCode session. + const response = await client.session.messages({ + sessionID, + ...(directory ? { directory } : {}), + }); + if (response.error || !Array.isArray(response.data)) { + log.warn("[SDK-DEBUG] session.messages returned no usable data", { + sessionId: sessionID, + status: response.response.status, + error: response.error, + hasData: response.data !== undefined, + directory, + }); + throw new Error( + `session.messages did not return an array (status ${response.response.status}): ${this.describeSdkError(response.error)}`, + ); + } + const messages = response.data; + + // Temporary SDK inspection mode: keep this as the unmodified `data` array + // returned by OpenCode. Do not run it through the event-tape normalizer or + // write it to workspaceState. This is intentionally verbose so the + // Extension Host log can show exactly what OpenCode returned. + log.info("[SDK-DEBUG] session.messages RAW data", { + sessionId: sessionID, + status: response.response.status, + directory, + messages, + }); + + return messages; + } + + async loadSessionMeta(sessionID: string): Promise { + const client = await this.serverManager.ensureRunning(); + const session = await this.loadEndpoint("session.get", sessionID, () => + client.session.get({ sessionID }) as Promise>, + ); + + if (!session) { + throw new Error(`Failed to load session metadata for ${sessionID}`); + } + + return session; + } + + private async loadEndpoint( + endpoint: string, + sessionID: string, + request: () => Promise>, + ): Promise { + try { + const response = await request(); + if (response.data === undefined) { + log.warn("Session snapshot endpoint returned no data", { + endpoint, + sessionId: sessionID, + status: response.response.status, + error: response.error, + }); + return undefined; + } + + if (response.error) { + log.warn("Session snapshot endpoint returned data with error", { + endpoint, + sessionId: sessionID, + status: response.response.status, + error: response.error, + }); + } + + return response.data; + } catch (error) { + log.warn("Session snapshot endpoint failed", { + endpoint, + sessionId: sessionID, + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + } + + private describeSdkError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error) ?? "unknown SDK error"; + } catch { + return "unserializable SDK error"; + } + } +} diff --git a/src/services/SubagentTracker.ts b/src/services/SubagentTracker.ts index 5bd48e5..7126841 100644 --- a/src/services/SubagentTracker.ts +++ b/src/services/SubagentTracker.ts @@ -428,7 +428,11 @@ function cloneReference(ref: SubagentReference): SubagentReference { function cloneSummary(summary: SubagentSummary): SubagentSummary { return { ...summary, - references: summary.references.map(cloneReference), + // Session persistence can contain older malformed summary payloads. Keep + // cloning total so one bad references value cannot take down the chat. + references: Array.isArray(summary.references) + ? summary.references.map(cloneReference) + : [], }; } diff --git a/tests/components/QuotaMonitor.test.mjs b/tests/components/QuotaMonitor.test.mjs index 47df099..4829414 100644 --- a/tests/components/QuotaMonitor.test.mjs +++ b/tests/components/QuotaMonitor.test.mjs @@ -19,8 +19,14 @@ const panelSource = readSource( 'PanelComponents.tsx', ); +const quotaMonitorSignature = 'export const QuotaMonitor = memo(function QuotaMonitor() {'; + +function getQuotaMonitorBody() { + return extractFunctionBody(panelSource, quotaMonitorSignature); +} + test('QuotaMonitor has refresh handler', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -30,7 +36,7 @@ test('QuotaMonitor has refresh handler', () => { }); test('QuotaMonitor refresh sets refreshing state', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -40,7 +46,7 @@ test('QuotaMonitor refresh sets refreshing state', () => { }); test('QuotaMonitor refresh requests backend refresh', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -50,7 +56,7 @@ test('QuotaMonitor refresh requests backend refresh', () => { }); test('QuotaMonitor has refresh button', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -65,7 +71,7 @@ test('QuotaMonitor has refresh button', () => { }); test('QuotaMonitor manages collapsible state', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -75,7 +81,7 @@ test('QuotaMonitor manages collapsible state', () => { }); test('QuotaMonitor has accessible collapse toggle', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -90,7 +96,7 @@ test('QuotaMonitor has accessible collapse toggle', () => { }); test('QuotaMonitor shows chevron indicator', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -100,7 +106,7 @@ test('QuotaMonitor shows chevron indicator', () => { }); test('QuotaMonitor conditionally renders content', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -110,7 +116,7 @@ test('QuotaMonitor conditionally renders content', () => { }); test('QuotaMonitor copies platforms before sorting', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -120,7 +126,7 @@ test('QuotaMonitor copies platforms before sorting', () => { }); test('QuotaMonitor sorts platforms alphabetically', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -135,7 +141,7 @@ test('QuotaMonitor sorts platforms alphabetically', () => { }); test('QuotaMonitor renders provider cards', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -150,7 +156,7 @@ test('QuotaMonitor renders provider cards', () => { }); test('QuotaMonitor renders error badge for error status', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -165,7 +171,7 @@ test('QuotaMonitor renders error badge for error status', () => { }); test('QuotaMonitor renders warning badge for warning status', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -180,7 +186,7 @@ test('QuotaMonitor renders warning badge for warning status', () => { }); test('QuotaMonitor omits badge for OK status', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, @@ -190,7 +196,7 @@ test('QuotaMonitor omits badge for OK status', () => { }); test('QuotaMonitor shows empty state', () => { - const quotaPanelBody = extractFunctionBody(panelSource, 'export function QuotaMonitor()'); + const quotaPanelBody = getQuotaMonitorBody(); assert.match( quotaPanelBody, diff --git a/tests/components/model-dropdown.test.mjs b/tests/components/model-dropdown.test.mjs index 9a6aed2..674a501 100644 --- a/tests/components/model-dropdown.test.mjs +++ b/tests/components/model-dropdown.test.mjs @@ -8,9 +8,20 @@ const panelSource = readSource( 'PanelComponents.tsx', ); +const modelDropdownSignature = 'export const ModelDropdown = memo(function ModelDropdown() {'; +const agentDropdownSignature = 'export const AgentDropdown = memo(function AgentDropdown() {'; + +function getModelDropdownBody() { + return extractFunctionBody(panelSource, modelDropdownSignature); +} + +function getAgentDropdownBody() { + return extractFunctionBody(panelSource, agentDropdownSignature); +} + test('model dropdown manages tab selection state', () => { // Verify state initialization and reset logic. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /const\s+\[selectedTab,\s*setSelectedTab\]\s*=\s*useState\("All"\)/, 'model dropdown should initialize selectedTab state to "All"'); assert.match(dropdownBody, /if\s*\(!modelDropdownOpen\)\s*\{[\s\S]*setSelectedTab\("All"\)/, 'model dropdown should reset tab to "All" when opening/closing'); @@ -18,7 +29,7 @@ test('model dropdown manages tab selection state', () => { test('model dropdown derives subscribed providers from quota platforms', () => { // Verify logic for generating chips from configured provider IDs returned by the SDK. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /const\s+subscribedProviders\s*=\s*useMemo\(/, 'subscribedProviders should be memoized'); assert.match(dropdownBody, /configuredProviders/, 'should read configuredProviders from app state'); @@ -32,7 +43,7 @@ test('model dropdown derives subscribed providers from quota platforms', () => { test('model dropdown groups and filters models based on selected tab', () => { // Verify filtering logic integrated into the grouping memo. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /if\s*\(selectedTab\s*!==\s*"All"\)/, 'grouping memo should check if a specific tab is selected'); assert.match(dropdownBody, /if\s*\(selectedTab\s*===\s*["']OpenCode Free["']\)\s*\{[\s\S]*return\s*model\.providerID\s*===\s*["']opencode["']/, 'should filter specifically for opencode models when tab is selected'); @@ -42,7 +53,7 @@ test('model dropdown groups and filters models based on selected tab', () => { test('model dropdown renders filter chips when subscriptions are present', () => { // Verify conditional rendering of the chip bar. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /subscribedProviders\.length\s*>\s*0/, 'chip bar should render only if providers exist'); assert.match(dropdownBody, /\[\s*["']All["'],\s*\.\.\.\s*subscribedProviders\s*\]\s*\.map\(/, 'should render All chip plus subscription chips'); @@ -51,7 +62,7 @@ test('model dropdown renders filter chips when subscriptions are present', () => test('model dropdown shows active state on selected filter chip', () => { // Verify styling based on tab state. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /selectedTab\s*===\s*tab[\s\S]*\?[\s\S]*["']bg-oc-accent text-white["']/, 'selected chip should have accent background'); assert.match(dropdownBody, /["']bg-oc-bg-soft oc-text-secondary hover:bg-oc-panel-soft hover:text-oc-text["']/, 'inactive chips should have subtle background'); @@ -59,7 +70,7 @@ test('model dropdown shows active state on selected filter chip', () => { test('model dropdown provides feedback when no models match filters', () => { // Verify empty state display within the popover. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /grouped\.size\s*===\s*0\s*&&\s*\(/, 'empty state should render when grouped map is empty'); assert.match(dropdownBody, /No models found/, 'empty state should show descriptive text'); @@ -68,7 +79,7 @@ test('model dropdown provides feedback when no models match filters', () => { test('model dropdown filters tabs by resolved providerName rather than legacy quota aliases', () => { // The current dropdown builds tabs from configured provider IDs plus available model provider names. // Filtering must continue to match against the visible providerName exposed by the model payload. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match( dropdownBody, @@ -85,7 +96,7 @@ test('model dropdown filters tabs by resolved providerName rather than legacy qu test('model dropdown suppresses only the exact opencode provider while preserving providers like OpenCode Go', () => { // Regression: SDK-backed configured providers can include OpenCode Go and similar names. // The dropdown must keep those tabs visible and only suppress the raw free-tier "opencode" provider ID. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match( dropdownBody, @@ -101,7 +112,7 @@ test('model dropdown suppresses only the exact opencode provider while preservin test('model dropdown displays full model and agent names without truncation', () => { // FIX: Remove max-w constraints to show full provider/model names in chips. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); // Model chip: should render label in a flex container with truncate assert.match( @@ -113,7 +124,7 @@ test('model dropdown displays full model and agent names without truncation', () test('agent dropdown displays full agent names without truncation', () => { // FIX: Remove max-w constraints to show full agent names in chips. - const agentBody = extractFunctionBody(panelSource, 'export function AgentDropdown()'); + const agentBody = getAgentDropdownBody(); // Agent chip: should render label in a flex container with truncate assert.match( @@ -125,7 +136,7 @@ test('agent dropdown displays full agent names without truncation', () => { test('model dropdown sorts models by provider and then by name', () => { // Verify sorting logic presence in the grouping memo. - const dropdownBody = extractFunctionBody(panelSource, 'export function ModelDropdown()'); + const dropdownBody = getModelDropdownBody(); assert.match(dropdownBody, /\.sort\(\(a,\s*b\)\s*=>\s*\{/, 'grouping memo should include sorting logic'); assert.match(dropdownBody, /a\.providerName\s*\?\?\s*a\.providerID/, 'should compare provider names/IDs'); diff --git a/tests/components/plan-detection.test.mjs b/tests/components/plan-detection.test.mjs index 7059a7a..93325f4 100644 --- a/tests/components/plan-detection.test.mjs +++ b/tests/components/plan-detection.test.mjs @@ -167,10 +167,10 @@ test('questionnaire-like content with plan keywords is not enriched as a plan', test('assistant message UI renders plan buttons and core plan card affordances', () => { // Verify the two plan entry points in message UI are present. - assert.match(messageSource, /title="Core Feature: View Implementation Plan"/, 'header-level plan button must keep core-feature tooltip'); + assert.match(messageSource, /title=\{`View \$\{plan\.title \|\| "Implementation Plan"\}`\}/, 'plan button should expose a descriptive view-plan tooltip'); assert.match(messageSource, /onClick=\{\(\)\s*=>\s*vscode\.postMessage\(\{\s*type:\s*["']viewPlan["'],\s*plan\s*\}\)\}/, 'plan button should dispatch viewPlan event'); assert.match(messageSource, /className="plan-card[^"]*"/, 'assistant message should render plan card container'); - assert.match(messageSource, /View Implementation Plan/, 'plan card must expose View Implementation Plan call-to-action'); + assert.match(messageSource, /View Plan/, 'plan card must expose a view-plan call-to-action'); }); test('chat provider extractMessageBodyText uses space separator between text parts', () => { diff --git a/tests/integration/code-selection-flow.test.mjs b/tests/integration/code-selection-flow.test.mjs index 3af12eb..ca839cc 100644 --- a/tests/integration/code-selection-flow.test.mjs +++ b/tests/integration/code-selection-flow.test.mjs @@ -61,11 +61,16 @@ describe('Code-selection part construction (ChatViewProvider)', () => { ); }); - test('deferred SDK prompt contexts always use text/plain instead of languageId-derived mime', () => { + test('code selection and file-reference prompt contexts use text/plain instead of languageId-derived mime', () => { assert.match( providerSource, - /for \(const context of options\.contexts \?\? \[\]\) \{[\s\S]*?promptFiles\.push\(\{[\s\S]*?mime:\s*"text\/plain"/, - 'deferred SDK prompt contexts must no longer derive mime from context.languageId', + /else if \(ctx\.content\)\s*\{[\s\S]*?type:\s*"file"[\s\S]*?mime:\s*"text\/plain"/, + 'code selection prompt parts must not derive MIME from context.languageId', + ); + assert.match( + providerSource, + /else if \(ctx\.file && workspaceFolder\)[\s\S]*?type:\s*"file"[\s\S]*?mime:\s*"text\/plain"/, + 'file-reference prompt parts must use text/plain MIME', ); assert.doesNotMatch( providerSource, @@ -74,23 +79,23 @@ describe('Code-selection part construction (ChatViewProvider)', () => { ); }); - test('deferred SDK prompt files and contexts keep full path metadata instead of basename-only names', () => { + test('SDK prompt files and contexts keep full path metadata instead of basename-only names', () => { assert.match( providerSource, - /promptFiles\.push\(\{[\s\S]*?uri:\s*path\.isAbsolute\(filePath\) \? vscode\.Uri\.file\(filePath\)\.toString\(\) : filePath,[\s\S]*?name:\s*filePath/, - 'plain attached files must preserve full filePath in the prompt file name metadata', + /for \(const filePath of files\) \{[\s\S]*?filename:\s*filePath\.split[\s\S]*?source:\s*\{[\s\S]*?path:\s*filePath/, + 'plain attached files must preserve full filePath in source.path metadata even when the filename is a short label', ); - // Code selections in the deferred path now use a data URI (not raw file path) + // Code selections use a data URI (not raw file path) // so the server can't resolve the whole file from disk. assert.match( providerSource, - /for \(const context of options\.contexts \?\? \[\]\) \{[\s\S]*?if \(content\) \{[\s\S]*?dataUri[\s\S]*?name:\s*nameWithLine/, - 'deferred code selections must use data URI with nameWithLine for AI metadata', + /else if \(ctx\.content\)\s*\{[\s\S]*?selectionDataUrl[\s\S]*?filename:\s*selectionPathWithLineInfo/, + 'code selections must use data URI with selectionPathWithLineInfo for AI metadata', ); assert.match( providerSource, - /const dataUri = `data:text\/plain;base64,\$\{Buffer\.from\([\s\S]*?content[\s\S]*?"base64"\)\}/, - 'deferred path must base64-encode selection content as data URI', + /const selectionDataUrl = `data:text\/plain;base64,\$\{Buffer\.from\([\s\S]*?selectionContent[\s\S]*?"base64"\)\}/, + 'prompt path must base64-encode selection content as data URI', ); }); diff --git a/tests/integration/conversation-pipeline.test.mjs b/tests/integration/conversation-pipeline.test.mjs index cedb16a..de0521f 100644 --- a/tests/integration/conversation-pipeline.test.mjs +++ b/tests/integration/conversation-pipeline.test.mjs @@ -401,7 +401,7 @@ describe("conversation pipeline", () => { it("StreamEventHandler handles subagentsDelta", () => { assert.match(streamHandlerSource, /subagentsDelta/, "must handle subagentsDelta"); - assert.match(streamHandlerSource, /subagentPersistence/, "must use subagentPersistence"); + assert.match(streamHandlerSource, /logSubagentUpdate/, "must handle live subagent updates"); }); it("StreamEventHandler enriches events via SOP", () => { diff --git a/tests/providers/chat-send-deduplication-regression.test.mjs b/tests/providers/chat-send-deduplication-regression.test.mjs index aa2bccc..3a648b6 100644 --- a/tests/providers/chat-send-deduplication-regression.test.mjs +++ b/tests/providers/chat-send-deduplication-regression.test.mjs @@ -75,8 +75,8 @@ test("webview send actions include a stable client request id", () => { ); assert.match( panelComponentsSource, - /const hasLiveAssistantTurn = shouldDeferComposerSendInCurrentSession\([\s\S]*\.\.\.\(hasLiveAssistantTurn \? \{ delivery: "deferred" \} : \{\}\)/s, - "composer sends should only use deferred delivery for confirmed live turns in the current session", + /const hasLiveAssistantTurn = shouldDeferComposerSendInCurrentSession\([\s\S]*const steerPrompt = \(\) => \{[\s\S]*if \(!text \|\| !hasLiveAssistantTurn \|\| isSteering\) return;[\s\S]*type: "steerMessage"/s, + "composer should route confirmed live-turn input through the steering path instead of manufacturing queued sends", ); assert.doesNotMatch( panelComponentsSource, diff --git a/tests/providers/chat-send-pipeline.test.mjs b/tests/providers/chat-send-pipeline.test.mjs index 558086a..a0e268b 100644 --- a/tests/providers/chat-send-pipeline.test.mjs +++ b/tests/providers/chat-send-pipeline.test.mjs @@ -13,7 +13,7 @@ test('schedulePromptDispatch exists with prompt mode parameter', () => { test('schedulePromptDispatch trims input text and computes effective mode', () => { const body = extractFunctionBody(source, ' private async schedulePromptDispatch('); assert.match(body, /const text = typeof payload\.text === "string" \? payload\.text\.trim\(\) : "";/, 'schedulePromptDispatch should trim incoming text'); - assert.match(body, /const effectiveMode = mode;/, 'schedulePromptDispatch should keep dispatch mode explicit'); + assert.match(body, /let effectiveMode = mode;/, 'schedulePromptDispatch should keep dispatch mode explicit before SDK status refinement'); assert.doesNotMatch(body, /mode === "send-now"[\s\S]*\? "steer"[\s\S]*: mode;/, 'schedulePromptDispatch should not auto-convert normal sends into steer from processing flags'); }); @@ -59,6 +59,17 @@ test('promptWithStructuredOutput exists and uses client.session.prompt', () => { assert.match(source, /client\.session\.prompt|session\.prompt|prompt\(/, 'should use session prompt API'); }); +test('structured-output format is verified through the SDK before a user prompt persists it', () => { + const body = extractFunctionBody(source, ' private async promptWithStructuredOutput('); + assert.match(source, /private ensureStructuredOutputFormatCompatibility\(client: any\): Promise/, 'provider should define a server compatibility probe'); + assert.match(source, /noReply:\s*true/, 'the probe must not invoke a model'); + assert.match(source, /client\.session\.messages\(/, 'the probe must verify the persisted format through SDK rehydration'); + assert.match(source, /client\.session\.delete\(/, 'the disposable probe session must be cleaned up'); + assert.match(body, /await this\.ensureStructuredOutputFormatCompatibility\(client\)/, 'a user prompt must wait for the compatibility probe'); + assert.match(body, /format:\s*schema/, 'structured output must use the SDK v2 format field'); + assert.doesNotMatch(body, /\["outputFormat"\]|outputFormat\s*:/, 'the legacy untyped outputFormat field must never be sent'); +}); + test('send failures surface friendly user-facing timeout text instead of raw internal labels', () => { const body = extractFunctionBody(source, ' private async handleSendMessage('); assert.match(body, /const userFacingErrorMessage =[\s\S]*getUserFacingSendErrorMessage\(errorMessage\)/s, 'send error handling should map internal errors to user-facing text before showing the banner'); @@ -121,6 +132,21 @@ test('handleStopRequest aborts the SDK session and cleans up processing state', assert.match(source, /processingSessionIds|clear|cleanup|delete/, 'should clean up processing state'); }); +test('handleStopRequest uses the SDK session abort route contract', () => { + const body = extractFunctionBody(source, ' private async handleStopRequest('); + assert.match( + body, + /client\.session\.abort\(\{\s*sessionID: resolvedSessionId,/s, + 'the SDK v2 client requires the session ID parameter', + ); + assert.match( + body, + /directory: workspaceDirectory/, + 'the SDK v2 client accepts the workspace directory parameter', + ); + assert.match(body, /result\.error/, 'non-throwing SDK errors must be logged'); +}); + test('handleStopRequest finalizes the UI before the best-effort SDK abort completes', () => { const body = extractFunctionBody(source, ' private async handleStopRequest('); const localFinalization = body.indexOf('this.processingSessionIds.delete(resolvedSessionId);'); @@ -177,17 +203,19 @@ test('pasted text attachments retain their content without requiring a workspace assert.doesNotMatch(body, /path:\s*attachment\.filename/, 'pasted data URLs must not claim their display filename is a workspace path'); }); -test('provider uses a centralized-first history loader for chat hydration paths', () => { - assert.match(source, /private async loadCentralizedRenderableHistory\(sessionId: string\): Promise { + assert.match(source, /private async loadSdkRenderableHistory\(sessionId: string\): Promise { +test('stream callback forwards a detached SDK event payload without persisting it', () => { const body = extractFunctionBody(source, ' this.unsubscribe = this.streamService.subscribe(async (event, rawEvent) => {'); - assert.match(body, /const centralizedEventPayload = \{\s*\.\.\.enrichedEvent,\s*sessionId: resolvedSessionId,\s*\};/s, 'stream persistence should use the normalized event payload with the resolved session id'); - assert.match(body, /appendRawSdkEventPayload\(\s*resolvedSessionId,\s*centralizedEventPayload,\s*\)/s, 'stream persistence should store the normalized centralized event payload'); - assert.doesNotMatch(body, /appendRawSdkEventPayload\(\s*resolvedSessionId,\s*rawEvent/s, 'stream persistence should not store raw SDK wrapper frames as the centralized tape item'); + assert.match(body, /const eventForWebview = this\.buildWebviewStreamEvent\(enrichedEvent \|\| event\);/, 'stream transport should build a detached webview payload from the SDK event'); + assert.match(body, /this\.enqueueStreamWebviewEvent\(\s*eventForWebview,\s*resolvedSessionId,/s, 'stream transport should carry session ownership in the webview envelope'); + assert.doesNotMatch(body, /appendRawSdkEventPayload/, 'live SDK events must not be persisted into a centralized tape'); + assert.doesNotMatch(body, /sessionId:\s*resolvedSessionId,\s*\n\s*part:/, 'stream transport must not mutate the SDK event with local fields'); }); diff --git a/tests/providers/session-revert-handler.test.mjs b/tests/providers/session-revert-handler.test.mjs index 69c9ef2..ba30f4f 100644 --- a/tests/providers/session-revert-handler.test.mjs +++ b/tests/providers/session-revert-handler.test.mjs @@ -143,7 +143,7 @@ test("handleUndoMessageChanges posts revertStateUpdate to webview before reloadi postMessageIndex > -1 && loadSessionIndex > postMessageIndex, "revertStateUpdate postMessage must occur before handleLoadSession reload", ); -} +}); test("handleUndoMessageChanges reloads session and sessions list after revert", () => { const body = extractHandleUndoMessageChanges(providerSource); diff --git a/tests/providers/stream-event-orchestration.test.mjs b/tests/providers/stream-event-orchestration.test.mjs index bac09a1..0942d84 100644 --- a/tests/providers/stream-event-orchestration.test.mjs +++ b/tests/providers/stream-event-orchestration.test.mjs @@ -42,18 +42,7 @@ test('subagent tracker consumes stream events before session gating', () => { ); }); -test('centralized event persistence is queued before stream events can return through session gates', () => { - const persistIndex = streamSubscribeBody.indexOf('enqueueRawSdkEventPersistence('); - const todoGateIndex = streamSubscribeBody.indexOf('handleSdkTodoUpdatedEvent('); - const compactionGateIndex = streamSubscribeBody.indexOf('handleSdkCompactionStreamEvent('); - assert.ok(persistIndex >= 0, 'stream callback should enqueue centralized persistence'); - assert.ok(todoGateIndex >= 0, 'stream callback should retain the todo gate'); - assert.ok(compactionGateIndex >= 0, 'stream callback should retain the compaction gate'); - assert.ok( - persistIndex < todoGateIndex && persistIndex < compactionGateIndex, - 'centralized persistence must happen before early-return gates', - ); -}); +// Centralized raw event persistence was removed; live stream gates now forward raw SDK events without writing an event tape. test('child subagent events are bucketed under their parent session for persistence and live debugging', () => { assert.match( @@ -68,8 +57,8 @@ test('child subagent events are bucketed under their parent session for persiste ); assert.match( streamSubscribeBody, - /const persistenceSessionId =[\s\S]*?subagentParentSessionId \|\|[\s\S]*?eventSessionId/s, - 'centralized raw persistence should prefer the parent session bucket', + /const resolvedSessionId =[\s\S]*?subagentParentSessionId \|\|[\s\S]*?eventSessionId/s, + 'stream forwarding should prefer the parent session bucket', ); }); diff --git a/tests/regression/diff-preview-block-grouping-regression.test.mjs b/tests/regression/diff-preview-block-grouping-regression.test.mjs index 43885e1..65165bf 100644 --- a/tests/regression/diff-preview-block-grouping-regression.test.mjs +++ b/tests/regression/diff-preview-block-grouping-regression.test.mjs @@ -143,35 +143,11 @@ test("ResponseMessage receives centralizedDiffEvent only for isLastInBlock cards // ── hasCentralizedSessionDiffEntries ─────────────────────────────── -test("hasCentralizedSessionDiffEntries detects diffs in both session.diff and message.updated events", () => { +test("hasVisibleCentralizedSessionDiffEntries detects visible session.diff entries", () => { assert.match( chatShellSource, - /const hasCentralizedSessionDiffEntries\s*=\s*useMemo/, - "must define hasCentralizedSessionDiffEntries memo", - ); - - assert.match( - chatShellSource, - /getCentralizedEventType\(event\)/, - "must use getCentralizedEventType for sync support", - ); - - assert.match( - chatShellSource, - /getCentralizedEventInfo\(event\)/, - "must use getCentralizedEventInfo for sync support", - ); - - assert.match( - chatShellSource, - /type\s*===\s*"session\.diff"/, - "must check session.diff type", - ); - - assert.match( - chatShellSource, - /type\s*===\s*"message\.updated"/, - "must check message.updated type", + /const hasVisibleCentralizedSessionDiffEntries\s*=\s*useMemo\(\s*\(\) => visibleConversationEntries\.some\(\(entry\) => entry\.kind === "session\.diff"\)/, + "visible transcript diff detection should use normalized session.diff entries from the raw SDK tape", ); }); diff --git a/tests/regression/file-mention-sdk-attachment-regression.test.mjs b/tests/regression/file-mention-sdk-attachment-regression.test.mjs new file mode 100644 index 0000000..094d3c9 --- /dev/null +++ b/tests/regression/file-mention-sdk-attachment-regression.test.mjs @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + extractFunctionBody, + joinFromRoot, + readSource, +} from "../helpers/source-utils.mjs"; + +const panelSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "PanelComponents.tsx")], + "PanelComponents.tsx", +); +const pendingSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "pendingUserMessages.ts")], + "pendingUserMessages.ts", +); +const providerSource = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); + +test("selected @ file mentions are sent as SDK-backed contexts", () => { + assert.match( + panelSource, + /export function resolveMentionedFileContexts\([\s\S]*?text\.matchAll\(FILE_MENTION_REGEX\)[\s\S]*?fileMentionPaths\[mention\]/, + "@ mention paths must be resolved from autocomplete selections", + ); + + const sendPrompt = extractFunctionBody(panelSource, "const sendPrompt = () =>"); + assert.match( + sendPrompt, + /const currentContexts = resolveMentionedFileContexts\([\s\S]*?text,[\s\S]*?selectedContexts,[\s\S]*?fileMentionPaths/, + "regular sends must include selected @ file mentions in contexts", + ); + assert.match( + sendPrompt, + /contexts:\s*currentContexts/, + "regular sends must forward resolved mention contexts to the extension host", + ); +}); + +test("steered messages attach selected @ file mentions too", () => { + const steerPrompt = extractFunctionBody(panelSource, "const steerPrompt = () =>"); + assert.match( + steerPrompt, + /contexts:\s*resolveMentionedFileContexts\([\s\S]*?text,[\s\S]*?selectedContexts,[\s\S]*?fileMentionPaths/, + "live steering must use the same @ mention attachment path", + ); +}); + +test("live optimistic and host-echo user bubbles retain @ file context paths", () => { + assert.match( + panelSource, + /attachments: currentAttachments,\s*contexts: currentContexts/, + "the optimistic webview message must retain resolved mention contexts", + ); + assert.match( + pendingSource, + /\(pending\.contexts \?\? \[\]\)[\s\S]*?source: \{[\s\S]*?path:/, + "pending bubbles must convert contexts into file parts with paths", + ); + assert.match( + providerSource, + /Array\.isArray\(contexts\)[\s\S]*?source: \{[\s\S]*?path:/, + "the host's immediate user-message echo must include context file parts", + ); +}); diff --git a/tests/regression/hydrated-file-mention-rendering-regression.test.mjs b/tests/regression/hydrated-file-mention-rendering-regression.test.mjs new file mode 100644 index 0000000..173e6af --- /dev/null +++ b/tests/regression/hydrated-file-mention-rendering-regression.test.mjs @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const source = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + +test("hydrated user bubbles exclude synthetic file-read echoes", () => { + assert.match( + source, + /const rawUserText =\s*messageBodyFromParts\(message\?\.parts, message\?\.role \?\? message\?\.info\?\.role\) \|\|/, + "UserMessage must prefer renderable message parts over hydrated top-level text", + ); + assert.match( + source, + /\(part as \{ synthetic\?: unknown \}\)\.synthetic === true\) \{\s*return false;/, + "synthetic SDK parts must remain excluded from visible user text", + ); + assert.match( + source, + /isSyntheticUserToolTextPart\(splitInjectedSystemPromptFromUserText\(text\)\.userText\)/, + "the synthetic-text guard must receive the user text string, not the split result object", + ); +}); + +test("attached file mentions use the SDK source path", () => { + assert.match( + source, + /const fileMentionTargets = Array\.from\([\s\S]*?filename: chip\.label, path: chip\.path/, + "file mention rendering must derive path targets from attached SDK file parts", + ); + assert.match( + source, + /file: part\.path \|\| \(part as any\)\.filename/, + "clicking a mention must use its resolved workspace path", + ); + assert.match( + source, + /const explicitFileChips = attachedFileChips\.filter\([\s\S]*?!hasInlineFileMention\(content, chip\.label\)/, + "inline @ references must not render a duplicate attachment chip", + ); +}); diff --git a/tests/regression/implementation-plan-generic-response-lock-regression.test.mjs b/tests/regression/implementation-plan-generic-response-lock-regression.test.mjs index b30d3a5..a3206f0 100644 --- a/tests/regression/implementation-plan-generic-response-lock-regression.test.mjs +++ b/tests/regression/implementation-plan-generic-response-lock-regression.test.mjs @@ -30,8 +30,8 @@ test("implementation_plan turns do not use a legacy structured text fallback in assert.match( messageComponentsSource, - /return getCentralizedAssistantContentChunksFromRawSdkEventPayloads\(\s*responseBodyRawSdkEventPayloads,\s*\);/s, - "assistant response fallback should come from centralized assistant text extraction only", + /const rawChunks = getCentralizedAssistantContentChunksFromRawSdkEventPayloads\(\s*responseBodyRawSdkEventPayloads,\s*\);[\s\S]*if \(rawChunks\.length > 0\) \{\s*return rawChunks;\s*\}/s, + "assistant response fallback should prefer raw SDK assistant text extraction before snapshot content", ); assert.match( diff --git a/tests/regression/inline-queue-delivery-regression.test.mjs b/tests/regression/inline-queue-delivery-regression.test.mjs new file mode 100644 index 0000000..c311471 --- /dev/null +++ b/tests/regression/inline-queue-delivery-regression.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + extractFunctionBody, + joinFromRoot, + readSource, +} from "../helpers/source-utils.mjs"; + +const panelSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "PanelComponents.tsx")], + "PanelComponents.tsx", +); +const providerSource = readSource( + [joinFromRoot("src", "providers", "ChatViewProvider.ts")], + "ChatViewProvider.ts", +); + +test("normal sends render inline immediately and do not mount the legacy queue list", () => { + const sendPrompt = extractFunctionBody(panelSource, "const sendPrompt = () =>"); + assert.match( + sendPrompt, + /const pendingSessionId[\s\S]*?dispatch\(\{\s*type: "ADD_PENDING_USER_MESSAGE"/, + "normal sends must add an inline optimistic user message", + ); + assert.doesNotMatch( + panelSource, + //, + "the composer must not mount the legacy extension queue list", + ); +}); + +test("a busy SDK session does not rewrite normal sends into QueueManager items", () => { + const scheduleBody = extractFunctionBody(providerSource, "private async schedulePromptDispatch("); + assert.doesNotMatch( + scheduleBody, + /statusType === "busy"[\s\S]*?effectiveMode = "queue"/, + "normal sends must remain direct rather than entering the extension queue", + ); +}); diff --git a/tests/regression/queue-visibility-drain-regression.test.mjs b/tests/regression/queue-visibility-drain-regression.test.mjs index b8d6f2f..123f31f 100644 --- a/tests/regression/queue-visibility-drain-regression.test.mjs +++ b/tests/regression/queue-visibility-drain-regression.test.mjs @@ -191,18 +191,18 @@ test.describe("Queue drain ordering", () => { // --------------------------------------------------------------------------- test.describe("Composer deferred delivery", () => { - test("sendPrompt requests deferred delivery only when the assistant is responding", () => { - const body = extractFunctionBody(panelSource, "export function InputWrapper()"); + test("sendPrompt renders normal sends as pending user messages, not extension queue rows", () => { + const body = extractFunctionBody(panelSource, "function InputWrapper()"); assert.match( body, - /\.\.\.\(hasLiveAssistantTurn \? \{ delivery: "deferred" \} : \{\}\)/, - "when the assistant is responding, sendPrompt must ask OpenCode to defer delivery server-side", + /type:\s*"ADD_PENDING_USER_MESSAGE"/, + "normal composer sends must render inline as pending user messages while OpenCode owns ordering", ); }); test("sendPrompt does not post addToQueue to the backend", () => { - const body = extractFunctionBody(panelSource, "export function InputWrapper()"); + const body = extractFunctionBody(panelSource, "function InputWrapper()"); assert.doesNotMatch( body, @@ -212,7 +212,7 @@ test.describe("Composer deferred delivery", () => { }); test("sendPrompt does not dispatch ADD_TO_LOCAL_QUEUE", () => { - const body = extractFunctionBody(panelSource, "export function InputWrapper()"); + const body = extractFunctionBody(panelSource, "function InputWrapper()"); assert.doesNotMatch( body, @@ -221,16 +221,16 @@ test.describe("Composer deferred delivery", () => { ); }); - test("accepted deferred prompts render from SDK acknowledgement, not QueueManager", () => { + test("normal prompts render from pending user message state, not QueueManager", () => { assert.match( - messageHandlerSource, - /case "deferredPromptAccepted":[\s\S]*ADD_PENDING_DEFERRED_PROMPT/, - "SDK-accepted deferred prompts should be tracked separately from QueueManager", + panelSource, + /type:\s*"ADD_PENDING_USER_MESSAGE"/, + "composer prompts should be tracked separately from QueueManager", ); - assert.match( + assert.doesNotMatch( panelSource, /delivery:\s*["']deferred["']/, - "the composer should still request OpenCode deferred delivery", + "the raw SDK event refactor removed the explicit deferred delivery payload field", ); }); }); @@ -323,7 +323,7 @@ test.describe("Queue store reducer", () => { test.describe("QueueContainer UI contract", () => { test("QueueContainer renders all items by default (no collapsed state)", () => { - const body = extractFunctionBody(panelSource, "export function QueueContainer()"); + const body = extractFunctionBody(panelSource, "function QueueContainer()"); assert.doesNotMatch( body, @@ -339,7 +339,7 @@ test.describe("QueueContainer UI contract", () => { }); test("QueueContainer shows Pending label with count badge", () => { - const body = extractFunctionBody(panelSource, "export function QueueContainer()"); + const body = extractFunctionBody(panelSource, "function QueueContainer()"); assert.match( body, @@ -355,7 +355,7 @@ test.describe("QueueContainer UI contract", () => { }); test("QueueContainer shows per-item remove button (X icon)", () => { - const body = extractFunctionBody(panelSource, "export function QueueContainer()"); + const body = extractFunctionBody(panelSource, "function QueueContainer()"); assert.match( body, @@ -371,7 +371,7 @@ test.describe("QueueContainer UI contract", () => { }); test("QueueContainer supports clear all action", () => { - const body = extractFunctionBody(panelSource, "export function QueueContainer()"); + const body = extractFunctionBody(panelSource, "function QueueContainer()"); assert.match( body, @@ -381,7 +381,7 @@ test.describe("QueueContainer UI contract", () => { }); test("QueueContainer does NOT render steer or send-now per-item buttons", () => { - const body = extractFunctionBody(panelSource, "export function QueueContainer()"); + const body = extractFunctionBody(panelSource, "function QueueContainer()"); assert.doesNotMatch( body, @@ -397,7 +397,7 @@ test.describe("QueueContainer UI contract", () => { }); test("QueueContainer shows numbered index per item", () => { - const body = extractFunctionBody(panelSource, "export function QueueContainer()"); + const body = extractFunctionBody(panelSource, "function QueueContainer()"); assert.match( body, diff --git a/tests/regression/rehydrated-task-tool-activity-regression.test.mjs b/tests/regression/rehydrated-task-tool-activity-regression.test.mjs new file mode 100644 index 0000000..a3c9773 --- /dev/null +++ b/tests/regression/rehydrated-task-tool-activity-regression.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const source = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); +const handlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); + +test("rehydrated SDK task tool parts project into the activity timeline", () => { + assert.match( + source, + /const fromSnapshotParts = progressItemsFromRawResponseParts\(\{\s*parts: cardMessage\?\.parts,\s*\}\);[\s\S]*?if \(fromSnapshotParts\.length > 0\)/, + "standalone hydrated message parts must become progress items when no event tape is available", + ); + assert.match( + source, + /const rawTitle = firstNonEmptyString\([\s\S]*?asString\(stateRec\?\.title\)/, + "task tools must use their SDK state title instead of a generic label", + ); +}); + +test("live task tool events use the same SDK state title and streaming activity path", () => { + assert.match( + handlerSource, + /const isProgressPartType =\s*partType === "tool"/, + "live tool events must be classified as streaming progress", + ); + assert.match( + handlerSource, + /asString\(part\.title\) \|\|\s*asString\(stateObj\?\.title\) \|\|\s*\(tool \? `Running \$\{tool\}\.\.\.`/, + "live task tools must prefer state.title before the generic running label", + ); + assert.match( + handlerSource, + /const activityMessageID = firstNonEmptyString\(\s*asString\(part\.messageID\),\s*asString\(part\.messageId\),\s*messageId,/, + "live task tools must prefer the SDK part message ID over stream fallbacks", + ); + assert.match( + handlerSource, + /messageID: step\.messageID \|\| current\.messageID/, + "streaming step updates must preserve their owning message ID", + ); +}); diff --git a/tests/regression/session-isolation-regression.test.mjs b/tests/regression/session-isolation-regression.test.mjs index 2f5e63c..22f2a1b 100644 --- a/tests/regression/session-isolation-regression.test.mjs +++ b/tests/regression/session-isolation-regression.test.mjs @@ -162,13 +162,13 @@ test('ChatViewProvider stamps active sessionId onto every streamEvent forwarded // raw server event does not carry a session ID field. assert.match( chatViewProviderSource, - /const\s+eventForWebview\s*=\s*\{\s*\.\.\.enrichedEvent\s*,\s*sessionId:\s*resolvedSessionId\s*\}/, - 'stream events must spread enrichedEvent and add sessionId: resolvedSessionId before forwarding', + /const\s+eventForWebview\s*=\s*this\.buildWebviewStreamEvent\(enrichedEvent \|\| event\)/, + 'stream events must be copied into a detached webview payload before forwarding', ); assert.match( chatViewProviderSource, /this\.enqueueStreamWebviewEvent\(\s*eventForWebview,\s*resolvedSessionId,/, - 'queued stream delivery must carry the resolved session ID', + 'queued stream delivery must carry the resolved session ID in the protocol envelope', ); }); diff --git a/tests/regression/store-message-canonicalization-regression.test.mjs b/tests/regression/store-message-canonicalization-regression.test.mjs index 880ddf3..a73a0b6 100644 --- a/tests/regression/store-message-canonicalization-regression.test.mjs +++ b/tests/regression/store-message-canonicalization-regression.test.mjs @@ -30,7 +30,7 @@ test("SET_MESSAGES uses centralized canonicalization before storing messages", ( ); }); -test("centralized canonicalization drops internal reminders and coalesces assistant runs", () => { +test("centralized canonicalization drops internal reminders and preserves per-phase assistant runs without coalescing", () => { const canonicalBody = extractFunctionBody( storeSource, "export function canonicalizeMessagesForRender(", @@ -45,10 +45,22 @@ test("centralized canonicalization drops internal reminders and coalesces assist /isInternalTransportReminderMessage\(message\)/, "canonicalization should drop internal transport reminder pseudo-messages", ); + // Design decision: `session.messages()` already returns complete, ordered SDK + // message envelopes. Consecutive assistant envelopes are meaningful phases + // (read → edit → final response), not duplicate fragments. Identity dedup + // removes true duplicates; the remaining assistant turns must NOT be coalesced + // here or their activity gets moved into the final card. This test locks in + // that decision — if someone re-adds `coalesceAssistantRunForCanonical(burst)` + // to this function, the per-phase activity timeline regresses. + assert.doesNotMatch( + canonicalBody, + /coalesceAssistantRunForCanonical\(/, + "canonicalization must NOT coalesce adjacent assistant runs — per-phase activity (read/edit/final) must remain visible", + ); assert.match( canonicalBody, - /coalesceAssistantRunForCanonical\(burst\)/, - "canonicalization should coalesce adjacent assistant runs into one canonical turn", + /dedupeAdjacentCanonicalTurns\(/, + "canonicalization should dedupe adjacent turns via dedupeAdjacentCanonicalTurns (identity-based, not coalescing)", ); assert.match( reminderBody, diff --git a/tests/regression/streaming-state-preservation-regression.test.mjs b/tests/regression/streaming-state-preservation-regression.test.mjs index aa2e3c5..6e96281 100644 --- a/tests/regression/streaming-state-preservation-regression.test.mjs +++ b/tests/regression/streaming-state-preservation-regression.test.mjs @@ -25,6 +25,10 @@ const messageHandlerSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], "messageHandler.ts", ); +const chatShellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); test("messageResponse handler preserves streaming state for non-matching messages", () => { // Check that finalMessageId is declared @@ -151,6 +155,50 @@ test("chatHistory handler should not clear rendered messages during active-sessi ); }); +test("empty same-session SDK hydration preserves the locked stop snapshot", () => { + assert.match( + messageHandlerSource, + /shouldPreserveEmptySameSessionSnapshot/, + "chatHistory should distinguish a transient empty SDK response from an authoritative empty session", + ); + assert.match( + messageHandlerSource, + /Ignoring empty SDK hydration snapshot for visible session/, + "the preservation path should be observable in webview logs", + ); + assert.match( + messageHandlerSource, + /CLEAR_LIVE_EVENT_STREAM_DEBUG[\s\S]*Hydration renders the SDK/, + "live debug events should clear only after the empty-snapshot guard", + ); +}); + +test("a late stream start cannot reopen a stopped session", () => { + assert.match( + messageHandlerSource, + /Only a new user message\s+\/\/ is allowed to open that session for rendering again\./, + "late continuation events must remain blocked after Stop", + ); +}); + +test("tagged server directives are normalized to system messages", () => { + assert.match( + messageHandlerSource, + /const angleTagPattern/, + "a leading angle-bracket tag should be recognized during hydration", + ); + assert.match( + messageHandlerSource, + /hasAngleTagPrefix/, + "tagged directives should be normalized to the system role", + ); + assert.match( + chatShellSource, + /role === "user" \? "user" : role === "system" \? "system" : "assistant"/, + "system envelopes should select SystemMessage instead of an assistant card", + ); +}); + test("assistant replacements merge activity instead of clearing rendered timelines", () => { // Assistant replacement merging has been refactored into the centralized message processing system assert.match( diff --git a/tests/regression/subagent-references-shape-regression.test.mjs b/tests/regression/subagent-references-shape-regression.test.mjs new file mode 100644 index 0000000..7862704 --- /dev/null +++ b/tests/regression/subagent-references-shape-regression.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const stateManagerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "subagents", "stateManager.ts")], + "subagent state manager", +); +const trackerSource = readSource( + [joinFromRoot("src", "services", "SubagentTracker.ts")], + "SubagentTracker", +); + +test("malformed subagent summary references cannot crash webview hydration", () => { + assert.match( + stateManagerSource, + /references: Array\.isArray\(summary\.references\) \? \[\.\.\.summary\.references\] : \[\]/, + "summary references must be normalized before spreading", + ); +}); + +test("malformed subagent summary references cannot crash extension cloning", () => { + assert.match( + trackerSource, + /references: Array\.isArray\(summary\.references\)[\s\S]*?summary\.references\.map\(cloneReference\)[\s\S]*?: \[\]/, + "summary references must be normalized before cloning", + ); +}); diff --git a/tests/regression/subagent-restart-cancellation.test.mjs b/tests/regression/subagent-restart-cancellation.test.mjs index 18f72cb..8d80468 100644 --- a/tests/regression/subagent-restart-cancellation.test.mjs +++ b/tests/regression/subagent-restart-cancellation.test.mjs @@ -21,11 +21,12 @@ const card = readSource( ); test("persisted live subagents become cancelled during extension-host recovery", () => { - assert.match(sessionService, /cancelStaleSubagentsAfterExtensionRestart/); - assert.match(sessionService, /recoverSubagentProjectionAfterRestart/); + // SessionService no longer owns subagent recovery after the raw SDK tape refactor. + assert.doesNotMatch(sessionService, /cancelStaleSubagentsAfterExtensionRestart/); assert.match(recovery, /status !== "pending" && status !== "running" && status !== "orphaned"/); assert.match(recovery, /status: "cancelled"/); - assert.match(provider, /cancelStaleSubagentsAfterExtensionRestart\(sessionId\)/); + assert.match(provider, /finalizeParentMessage\(\{/); + assert.match(provider, /parentSessionId:\s*resolvedSessionId/); }); test("cancelled is terminal in the inline subagent card", () => { diff --git a/tests/regression/task-tool-message-card-ownership-regression.test.mjs b/tests/regression/task-tool-message-card-ownership-regression.test.mjs new file mode 100644 index 0000000..9d47830 --- /dev/null +++ b/tests/regression/task-tool-message-card-ownership-regression.test.mjs @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const handlerSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], + "messageHandler.ts", +); + +test("a completed task-tool envelope does not replace a different streaming message", () => { + assert.match( + handlerSource, + /replaceMatchingAssistantTurn\([\s\S]*?\.\.\.\(shouldDropMismatchedSnapshot\s*\? \[\]\s*:\s*\[streamingMessageId, snapshotMessageId\]\)/, + "mismatched streaming IDs must not be candidates for replacing a completed SDK message", + ); + assert.doesNotMatch( + handlerSource, + /replaceMatchingAssistantTurn\(currentMessages, sanitized, \[\s*finalMessageId,\s*responseMessageId,\s*streamingMessageId/, + "the unconditional mixed-ID replacement path must not return", + ); +}); + +test("a distinct task-tool envelope does not finalize the active streaming card", () => { + assert.match( + handlerSource, + /const snapshotStreaming = shouldDropMismatchedSnapshot\s*\? null\s*:\s*currentStreaming \?\? latestStreamingSnapshot/, + "a mismatched task-tool result must not absorb the active stream's activity", + ); + assert.match( + handlerSource, + /!shouldDropMismatchedSnapshot &&\s*\(hasOwnResponsePayload \|\| interactiveEventsInResponse\.length > 0\)/, + "a mismatched task-tool result must not clear the unrelated active stream", + ); +}); diff --git a/tests/services/sdk-message-adapter.test.mjs b/tests/services/sdk-message-adapter.test.mjs new file mode 100644 index 0000000..ea7c797 --- /dev/null +++ b/tests/services/sdk-message-adapter.test.mjs @@ -0,0 +1,652 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { extractFunctionBody, joinFromRoot, readSource } from '../helpers/source-utils.mjs'; + +const adapterSource = readSource( + [joinFromRoot('src', 'services', 'SdkMessageAdapter.ts')], + 'SdkMessageAdapter.ts', +); + +const snapshotLoaderSource = readSource( + [joinFromRoot('src', 'services', 'SessionSnapshotLoader.ts')], + 'SessionSnapshotLoader.ts', +); + +// ── Phase 2a: SDK-message adapter contract tests ── + +test('adaptSdkMessage exports main adapter function', () => { + assert.match( + adapterSource, + /export function adaptSdkMessage\(sdkMessage: SdkMessageEnvelope\): SdkRenderedMessage/, + 'adaptSdkMessage should be exported with correct signature', + ); +}); + +test('adaptSdkMessages exports batch adapter function', () => { + assert.match( + adapterSource, + /export function adaptSdkMessages\(sdkMessages: SdkMessageEnvelope\[\]\): SdkRenderedMessage\[\]/, + 'adaptSdkMessages should be exported with array signature', + ); +}); + + +test('adaptStructuredOutput handles all structured response types', () => { + // Must handle plan, question, and generic message response types + assert.match( + adapterSource, + /implementation_plan|plan !== undefined/, + 'should detect implementation_plan from structured data', + ); + assert.match( + adapterSource, + /question !== undefined \? "question"/, + 'should detect question response type', + ); + assert.match( + adapterSource, + /: "message"/, + 'should fall back to message response type', + ); +}); + +test('adaptStructuredOutput extracts plan with all expected fields', () => { + // Must pull file, content, title, intro, summary, files, fileCount from plan + assert.match( + adapterSource, + /result\.plan = \{/, + 'should build plan object from structured data', + ); + assert.match( + adapterSource, + /file: optionalString\(plan\.file\)/, + 'should extract plan file path', + ); + assert.match( + adapterSource, + /content: optionalString\(plan\.content\)/, + 'should extract plan content', + ); + assert.match( + adapterSource, + /title: optionalString\(plan\.title\)/, + 'should extract plan title', + ); + assert.match( + adapterSource, + /fileCount: optionalNumber/, + 'should compute fileCount field', + ); +}); + +test('adaptStructuredOutput extracts question with interactive event', () => { + assert.match( + adapterSource, + /questionRecordToInteractiveEvent\(question/, + 'should convert question record to interactive event', + ); + assert.match( + adapterSource, + /result\.interactiveEvents = \[event\]/, + 'should attach interactive event to result', + ); +}); + +test('all supported SDK Part types have adapter branches', () => { + // Each part type must have a corresponding if-branch in the part loop + assert.match(adapterSource, /part\.type === "text"/, 'should handle TextPart'); + assert.match(adapterSource, /part\.type === "reasoning"/, 'should handle ReasoningPart'); + assert.match(adapterSource, /part\.type === "tool"/, 'should handle ToolPart'); + assert.match(adapterSource, /part\.type === "step-start"/, 'should handle StepStartPart'); + assert.match(adapterSource, /part\.type === "step-finish"/, 'should handle StepFinishPart'); + assert.match(adapterSource, /part\.type === "patch"/, 'should handle PatchPart'); + assert.match(adapterSource, /part\.type === "snapshot"/, 'should handle SnapshotPart'); + assert.match(adapterSource, /part\.type === "subtask"/, 'should handle SubtaskPart'); + assert.match(adapterSource, /part\.type === "file"/, 'should handle FilePart'); + assert.match(adapterSource, /part\.type === "agent"/, 'should handle AgentPart'); + assert.match(adapterSource, /part\.type === "retry"/, 'should handle RetryPart'); + assert.match(adapterSource, /part\.type === "compaction"/, 'should handle CompactionPart'); +}); + +test('TextPart adapter produces text content and parts', () => { + assert.match( + adapterSource, + /textParts\.push\(textPart\.text\)/, + 'should collect text from TextPart into textParts array', + ); + assert.match( + adapterSource, + /message\.parts\?\.push\(\{ type: "text", text: textPart\.text \}\)/, + 'should push text as MessagePart with type=text', + ); + assert.match( + adapterSource, + /message\.content = content/, + 'should set message.content from joined text', + ); +}); + +test('ReasoningPart adapter produces reasoningEvents with id and timing', () => { + assert.match( + adapterSource, + /message\.reasoningEvents\?\.push\(\{/, + 'should push to reasoningEvents array', + ); + assert.match( + adapterSource, + /id: reasoningPart\.id/, + 'should include part id', + ); + assert.match( + adapterSource, + /text: reasoningPart\.text/, + 'should include reasoning text', + ); + assert.match( + adapterSource, + /createdAt: reasoningPart\.time\?\.start/, + 'should extract timing from reasoning time.start', + ); + assert.match( + adapterSource, + /messageID: info\.id/, + 'should link reasoning to parent message', + ); +}); + +test('ToolPart adapter produces steps with activity detail', () => { + assert.match( + adapterSource, + /function adaptToolPart\(part: ToolPart\): SdkMessageStep/, + 'adaptToolPart should have correct signature', + ); + assert.match( + adapterSource, + /type: "tool"/, + 'step type should be tool', + ); + assert.match( + adapterSource, + /callID: part\.callID/, + 'should include tool callID for dedup', + ); + assert.match( + adapterSource, + /status: part\.state\.status/, + 'should propagate tool state status', + ); + assert.match( + adapterSource, + /kind: "tool_call"/, + 'activityDetail should be tool_call kind', + ); + assert.match( + adapterSource, + /tool: part\.tool/, + 'should include tool name', + ); + assert.match( + adapterSource, + /input: part\.state\.input/, + 'should include tool input', + ); + assert.match( + adapterSource, + /function toolDiffProjection\(/, + 'should project SDK tool metadata through one diff fallback policy', + ); + assert.match( + adapterSource, + /toRecord\(metadata\?\.filediff\)/, + 'should support SDK state.metadata.filediff', + ); + assert.match( + adapterSource, + /optionalString\(input\?\.filePath\)/, + 'should use SDK state.input.filePath when state.filePath is absent', + ); + assert.match( + adapterSource, + /diffExcerpt: diff\.diffExcerpt/, + 'should expose an inline activity diff preview', + ); +}); + +test('SDK summary diffs are projected as one owner-addressable change summary', () => { + assert.match( + adapterSource, + /function summaryDiffProjection\(info: Message\): SdkMessageChangeSummary \| undefined/, + 'should project info.summary.diffs at the SDK boundary', + ); + assert.match( + adapterSource, + /messageId: info\.id/, + 'the SDK envelope ID must remain the Undo target', + ); + assert.match( + adapterSource, + /changeSummary: summaryDiffProjection\(info\)/, + 'every adapted message should carry its own SDK change summary when present', + ); + assert.match( + adapterSource, + /Keep that ownership intact/, + 'should preserve the SDK message that owns the diff summary', + ); + assert.doesNotMatch( + adapterSource, + /assistantIndexesByParentId/, + 'should not move a user-owned SDK summary onto a neighbouring assistant message', + ); +}); + +test('StepStartPart and StepFinishPart produce internal timeline steps', () => { + assert.match( + adapterSource, + /function adaptStepStartPart\(part: StepStartPart\): SdkMessageStep/, + 'adaptStepStartPart should exist', + ); + assert.match( + adapterSource, + /function adaptStepFinishPart\(part: StepFinishPart\): SdkMessageStep/, + 'adaptStepFinishPart should exist', + ); + assert.match( + adapterSource, + /internal: true/, + 'both step parts should be marked internal', + ); + assert.match( + adapterSource, + /type: "step-start"/, + 'step-start type should be preserved', + ); + assert.match( + adapterSource, + /type: "step-finish"/, + 'step-finish type should be preserved', + ); +}); + +test('PatchPart adapter produces edits from file list', () => { + assert.match( + adapterSource, + /function adaptPatchPart\(part: PatchPart\): SdkMessageEdit\[\]/, + 'adaptPatchPart should return array of edits', + ); + assert.match( + adapterSource, + /files\.map\(\(file\): SdkMessageEdit =>/, + 'should map files to MessageEdit objects', + ); + assert.match( + adapterSource, + /file,/, + 'each edit should include file path', + ); + assert.match( + adapterSource, + /added: diffStats\?\.added/, + 'should include added line count', + ); + assert.match( + adapterSource, + /deleted: diffStats\?\.deleted/, + 'should include deleted line count', + ); +}); + +test('SnapshotPart adapter creates checkpoint metadata step', () => { + assert.match( + adapterSource, + /type: "snapshot"/, + 'should produce snapshot-type step', + ); + assert.match( + adapterSource, + /title: "Checkpoint"/, + 'should label as Checkpoint', + ); + assert.match( + adapterSource, + /meta: snapshotPart\.snapshot/, + 'should store snapshot text as meta', + ); +}); + +test('SubtaskPart adapter creates SubagentDetail for subagent linkage', () => { + assert.match( + adapterSource, + /function adaptSubtaskPart\(part: SubtaskPart, info: Message\): SdkSubagentDetail/, + 'should export adaptSubtaskPart with Message context', + ); + assert.match( + adapterSource, + /childSessionId: part\.sessionID/, + 'should link child session from SubtaskPart', + ); + assert.match( + adapterSource, + /parentMessageId: part\.messageID/, + 'should link parent message from SubtaskPart', + ); + assert.match( + adapterSource, + /agentId: part\.agent/, + 'should include agent ID', + ); + assert.match( + adapterSource, + /status: "pending"/, + 'should set initial status to pending', + ); + assert.match( + adapterSource, + /message\.subagents\?\.push\(/, + 'should push to parent message subagents array', + ); +}); + +test('FilePart adapter includes url, filename, mime, and source', () => { + assert.match( + adapterSource, + /function adaptFilePart\(part: FilePart\): SdkMessagePart/, + 'adaptFilePart should return SdkMessagePart', + ); + assert.match( + adapterSource, + /type: "file"/, + 'should set type to file', + ); + assert.match( + adapterSource, + /url: part\.url/, + 'should include file url', + ); + assert.match( + adapterSource, + /filename: part\.filename/, + 'should include filename', + ); + assert.match( + adapterSource, + /mime: part\.mime/, + 'should include mime type', + ); +}); + +test('AgentPart adapter updates message agent field', () => { + assert.match( + adapterSource, + /message\.agent = agentPart\.name/, + 'should set message agent from AgentPart', + ); + assert.match( + adapterSource, + /agent: agentPart\.name/, + 'should update info.agent field', + ); +}); + +test('RetryPart adapter sets retry state with timing and error', () => { + assert.match( + adapterSource, + /function applyRetryMarker\(message: SdkRenderedMessage, part: RetryPart\)/, + 'should have applyRetryMarker function', + ); + assert.match( + adapterSource, + /retryState = "retrying_without_structured_output"/, + 'should set retry state string', + ); + assert.match( + adapterSource, + /retryStartedAt = part\.time\.created/, + 'should capture retry start time', + ); + assert.match( + adapterSource, + /retryMessage = errorToString\(part\.error\)/, + 'should extract error message', + ); +}); + +test('CompactionPart adapter sets summary with overflow and auto detection', () => { + assert.match( + adapterSource, + /function applyCompactionMarker\(message: SdkRenderedMessage, part: CompactionPart\)/, + 'should have applyCompactionMarker function', + ); + assert.match( + adapterSource, + /part\.overflow \? "Context compacted after overflow"/, + 'should detect overflow-triggered compaction', + ); + assert.match( + adapterSource, + /part\.auto \? "Context compacted automatically"/, + 'should detect auto-triggered compaction', + ); +}); + +test('adaptQuestionRequest converts SDK QuestionRequest to interactive event', () => { + assert.match( + adapterSource, + /export function adaptQuestionRequest\(q: QuestionRequest\): SdkInteractiveQuestionEvent/, + 'should export adaptQuestionRequest with correct signature', + ); + assert.match( + adapterSource, + /firstQuestion = toRecord\(q\.questions\[0\]\)/, + 'should extract first question from array', + ); + assert.match( + adapterSource, + /requestID: q\.id/, + 'should set requestID from QuestionRequest id', + ); + assert.match( + adapterSource, + /\?\? \{\s*type: "question"/, + 'should have fallback for empty questions using nullish coalescing', + ); +}); + +test('adaptPermissionRequest extracts all permission fields', () => { + assert.match( + adapterSource, + /export function adaptPermissionRequest\(p: PermissionRequest\)/, + 'should export adaptPermissionRequest', + ); + assert.match( + adapterSource, + /id: p\.id/, + 'should include permission id', + ); + assert.match( + adapterSource, + /sessionID: p\.sessionID/, + 'should include session ID', + ); + assert.match( + adapterSource, + /permission: p\.permission/, + 'should include permission name', + ); + assert.match( + adapterSource, + /patterns: p\.patterns/, + 'should include file patterns', + ); + assert.match( + adapterSource, + /tool: p\.tool/, + 'should include tool linkage (messageID + callID)', + ); +}); + +test('adaptSessionToMeta converts SDK Session to webview Session shape', () => { + assert.match( + adapterSource, + /export function adaptSessionToMeta\(session: Session\)/, + 'should export adaptSessionToMeta', + ); + assert.match( + adapterSource, + /id: session\.id/, + 'should include session id', + ); + assert.match( + adapterSource, + /title: session\.title/, + 'should include title', + ); + assert.match( + adapterSource, + /createdAt: session\.time\?\.created/, + 'should extract created time', + ); + assert.match( + adapterSource, + /parentSessionId: session\.parentID/, + 'should extract parent session ID', + ); +}); + +test('AssistantMessage structured output and error fields are preserved', () => { + assert.match( + adapterSource, + /function isAssistantMessage\(info: Message\): info is AssistantMessage/, + 'should have assistant message type guard', + ); + assert.match( + adapterSource, + /message\.error = errorToString\(info\.error\)/, + 'should extract error from assistant message', + ); + assert.match( + adapterSource, + /message\.tokens = info\.tokens/, + 'should extract tokens from assistant message', + ); + assert.match( + adapterSource, + /function durationSeconds\(info: AssistantMessage\): number \| undefined/, + 'should define the SDK-to-webview duration unit at the adapter boundary', + ); + assert.match( + adapterSource, + /return \(completed - created\) \/ 1000/, + 'should normalize SDK millisecond timestamps to UI seconds', + ); + assert.match( + adapterSource, + /messageInfo\.finish = info\.finish !== undefined/, + 'should flag finished messages', + ); +}); + +test('UserMessage image attachments are extracted from FileParts', () => { + assert.match( + adapterSource, + /function isUserMessage\(info: Message\): info is UserMessage/, + 'should have user message type guard', + ); + assert.match( + adapterSource, + /filePart\.mime\.startsWith\("image\/"\)/, + 'should detect image mime types', + ); + assert.match( + adapterSource, + /images\.push\(filePart\.url\)/, + 'should collect image URLs', + ); + assert.match( + adapterSource, + /message\.images = images/, + 'should set images on message', + ); +}); + +test('SessionSnapshotLoader loads all SDK endpoints in parallel', () => { + assert.match( + snapshotLoaderSource, + /Promise\.all\(\[/, + 'should use Promise.all for parallel loading', + ); + assert.match( + snapshotLoaderSource, + /client\.session\.get\(\{ sessionID, \.\.\.sessionScope \}\)/, + 'should load session metadata', + ); + assert.match( + snapshotLoaderSource, + /client\.session\.messages\(\{ sessionID, \.\.\.sessionScope \}\)/, + 'should load session messages', + ); + assert.match( + snapshotLoaderSource, + /client\.question\.list\(\)/, + 'should load pending questions', + ); + assert.match( + snapshotLoaderSource, + /client\.permission\.list\(\)/, + 'should load pending permissions', + ); + assert.match( + snapshotLoaderSource, + /client\.session\.children\(\{ sessionID, \.\.\.sessionScope \}\)/, + 'should load child sessions', + ); +}); + +test('SessionSnapshotLoader filters questions and permissions by sessionID', () => { + assert.match( + snapshotLoaderSource, + /question\.sessionID === sessionID/, + 'should filter questions by sessionID', + ); + assert.match( + snapshotLoaderSource, + /permission\.sessionID === sessionID/, + 'should filter permissions by sessionID', + ); +}); + +test('SessionSnapshotLoader handles partial failures gracefully', () => { + assert.match( + snapshotLoaderSource, + /catch \(error\)/, + 'should catch individual endpoint errors', + ); + assert.match( + snapshotLoaderSource, + /return undefined/, + 'should return undefined for failed endpoints', + ); + assert.match( + snapshotLoaderSource, + /messages: messages \?\? \[\]/, + 'should default messages to empty array on failure', + ); + assert.match( + snapshotLoaderSource, + /throw new Error\(`Failed to load session metadata/, + 'should throw only when session.get fails (critical)', + ); +}); + +test('SessionSnapshotLoader provides convenience methods', () => { + assert.match( + snapshotLoaderSource, + /async loadMessagesOnly\(sessionID: string\)/, + 'should have loadMessagesOnly convenience method', + ); + assert.match( + snapshotLoaderSource, + /async loadSessionMeta\(sessionID: string\)/, + 'should have loadSessionMeta convenience method', + ); +}); diff --git a/tests/services/session-crud.test.mjs b/tests/services/session-crud.test.mjs index 01eda61..a328633 100644 --- a/tests/services/session-crud.test.mjs +++ b/tests/services/session-crud.test.mjs @@ -204,53 +204,7 @@ test('chat provider no longer accepts legacy assistant snapshot persistence from ); }); -test('centralized subagent projections are scoped to their parent session', () => { - const scopeBody = extractFunctionBody( - sessionServiceSource, - 'private scopeSubagentProjectionToSession(', - ); - assert.match( - scopeBody, - /summary\?\.parentSessionId === sessionId/, - 'summary entries from other sessions must be excluded', - ); - assert.match( - scopeBody, - /detail\?\.parentSessionId === sessionId/, - 'detail entries from other sessions must be excluded', - ); - assert.match( - sessionServiceSource, - /this\.scopeSubagentProjectionToSession\(\s*sessionId,\s*this\.normalizeSubagentProjection\(update\)/s, - 'live subagent persistence must scope incoming updates before merging them', - ); -}); - -test('session service upgrades duplicate raw sdk event identities to the richer payload', () => { - assert.match( - sessionServiceSource, - /function rawSdkEventPersistenceRichness\(event: unknown\): number/, - 'session raw SDK persistence should define a richness scorer for duplicate event upgrades', - ); - assert.match( - sessionServiceSource, - /const existingIndex = identityIndex\.get\(eventIdentity\)[\s\S]*rawSdkEventPersistenceRichness\(snapshot\)\s*>=[\s\S]*rawSdkEventPersistenceRichness\(existingEvent\)/s, - 'duplicate raw SDK events should keep the richer payload instead of dropping later updates', - ); -}); - -test('session service snapshots raw sdk payloads into plain data before persistence', () => { - assert.match( - sessionServiceSource, - /import \{ createPlainObjectSnapshot \} from "\.\.\/shared\/createPlainObjectSnapshot";/, - 'SessionService should import the shared plain-object snapshot helper', - ); - assert.match( - sessionServiceSource, - /private cloneRawSdkEventPayload\(value: T\): T \{\s*return createPlainObjectSnapshot\(value\);\s*\}/, - 'SessionService should never fall back to returning the original raw SDK event object', - ); -}); +// Raw SDK event-tape persistence was removed; hydration now snapshots SDK messages directly through SessionSnapshotLoader/SdkMessageAdapter. test('history sidebar emits session create/switch/delete events to extension', () => { // Verify webview session controls post expected protocol messages. diff --git a/tests/unit/activity-timeline-collapse.test.mjs b/tests/unit/activity-timeline-collapse.test.mjs index f7974f8..c00106a 100644 --- a/tests/unit/activity-timeline-collapse.test.mjs +++ b/tests/unit/activity-timeline-collapse.test.mjs @@ -12,6 +12,10 @@ const chatShellSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], "ChatShell.tsx", ); +const assistantBlockPresentationSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "assistantBlockPresentation.ts")], + "assistantBlockPresentation.ts", +); test("activity timeline tracks an explicit expanded-vs-collapsed assistant turn state", () => { @@ -137,13 +141,13 @@ test("collapsed mode keeps only the last response chunk visible while earlier as test("multi-card block collapse maintains visibility of the final text-containing card", () => { assert.match( - chatShellSource, + assistantBlockPresentationSource, /lastTextIndexByKey\.set\(key, index\)/, "block evaluation should track the last card that actually contains text", ); assert.match( - chatShellSource, - /isLastTextInBlockByIndex\.set\(index, isLastText\)/, + assistantBlockPresentationSource, + /isLastTextInBlockByIndex\.set\(\s*index,[\s\S]*lastTextIndex === undefined \? isAbsoluteLast : lastTextIndex === index/s, "block evaluation should tag the text-bearing card as the logical last for collapse purposes", ); }); diff --git a/tests/unit/centralized-debug-payload-filter-regression.test.mjs b/tests/unit/centralized-debug-payload-filter-regression.test.mjs index 9d6c2c8..c68a322 100644 --- a/tests/unit/centralized-debug-payload-filter-regression.test.mjs +++ b/tests/unit/centralized-debug-payload-filter-regression.test.mjs @@ -172,7 +172,7 @@ test("generated centralized debug filter stays aligned with source for /global/e ); }); -test("centralized session persistence uses the shared helper and excludes delta-bearing payloads", () => { +test("centralized session persistence policy excludes delta-bearing payloads", () => { assert.doesNotMatch( source, /const CENTRALIZED_SESSION_PERSISTED_EVENT_TYPES = new Set\(/, @@ -208,9 +208,5 @@ test("centralized session persistence uses the shared helper and excludes delta- /Live-only UI events such as tui\.toast\.show and reasoning chunk frames are[\s\S]*excluded separately/, "generated filter should document the same live-only exclusions", ); - assert.match( - sessionServiceSource, - /private shouldPersistRawSdkEventPayload\(event: unknown\): boolean \{\s*return shouldPersistCentralizedSessionEventPayload\(event\);\s*\}/, - "SessionService should defer raw centralized persistence decisions to the shared centralized payload helper", - ); + // Obsolete after raw SDK event-driven chat: SessionService no longer owns raw centralized event persistence. }); diff --git a/tests/unit/centralized-filter-normalizer-paths.test.mjs b/tests/unit/centralized-filter-normalizer-paths.test.mjs index 3302362..4e980ce 100644 --- a/tests/unit/centralized-filter-normalizer-paths.test.mjs +++ b/tests/unit/centralized-filter-normalizer-paths.test.mjs @@ -147,38 +147,4 @@ test("disposition returns live-only for session.status and tui events", () => { ); }); -test("messageHandler only appends to centralized tape when disposition is persist", () => { - const handlerSource = readSource( - [joinFromRoot("webview", "shared", "src", "chat", "lib", "messageHandler.ts")], - "messageHandler.ts", - ); - - // streamEvent path - const streamEventSection = handlerSource.slice( - handlerSource.indexOf('case "streamEvent"'), - handlerSource.indexOf('case "streamEventBatch"'), - ); - - assert.match( - streamEventSection, - /centralizedDisposition === "persist"/, - "streamEvent must only append to centralized tape when disposition is persist", - ); - - assert.doesNotMatch( - streamEventSection, - /centralizedDisposition !== "excluded-noise"/, - "streamEvent must NOT use the old excluded-noise check (that was the leak)", - ); - - // streamEventBatch path - const batchSection = handlerSource.slice( - handlerSource.indexOf('case "streamEventBatch"'), - ); - - assert.match( - batchSection, - /disposition === "persist"/, - "streamEventBatch must only append to centralized tape when disposition is persist", - ); -}); +// Obsolete after raw SDK event-driven chat: stream events no longer append to a centralized webview tape. diff --git a/tests/webview/activity-timeline-attached-payloads-regression.test.mjs b/tests/webview/activity-timeline-attached-payloads-regression.test.mjs index ef53654..7cd630e 100644 --- a/tests/webview/activity-timeline-attached-payloads-regression.test.mjs +++ b/tests/webview/activity-timeline-attached-payloads-regression.test.mjs @@ -22,14 +22,14 @@ test("assistant cards render only message-ID-scoped centralized payloads", () => assert.match( body, - /if \(messageSpecificEvents\.length > 0\) \{\s*return messageSpecificEvents;\s*\}/s, - "a response card must not supplement a matching assistant turn with session-wide rows", + /if \(messageSpecificEvents\.length > 0\) \{\s*return \[[\s\S]*\.\.\.messageSpecificEvents,[\s\S]*\.\.\.sessionScopedNoIdPayloads,[\s\S]*\.\.\.attachedPayloadsWithoutIds,[\s\S]*\];\s*\}/s, + "a response card should combine matching assistant-turn rows with guarded ID-less raw SDK metadata", ); assert.match( body, - /if \(scopedAttachedPayloads\.length > 0\) \{\s*return scopedAttachedPayloads;\s*\}/s, - "the attached-payload fallback must use the identical message-ID predicate", + /if \(scopedAttachedPayloads\.length > 0\) \{[\s\S]*return \[[\s\S]*\.\.\.scopedAttachedPayloads,[\s\S]*\.\.\.sessionScopedNoIdPayloads,[\s\S]*\.\.\.attachedPayloadsWithoutIds,[\s\S]*\];\s*\}/s, + "the attached-payload fallback must use the identical message-ID predicate plus guarded ID-less metadata", ); }); @@ -51,15 +51,15 @@ test("session-wide and ID-less events cannot bleed into an assistant response bl "const centralizedRawSdkEventPayloads = useMemo(() => {", ); - assert.doesNotMatch( - body, - /sessionScopedNoIdPayloads|attachedPayloadsWithoutIds|isAssistantScopedNoIdPayloadCandidate/, - "without an explicit message ID, a payload has no safe response-block owner", + assert.match( + source, + /function isAssistantScopedNoIdPayloadCandidate\(event: unknown\): boolean \{[\s\S]*extractSemanticEventMessageId\(event\)[\s\S]*return false;[\s\S]*isAssistantHeaderMetadataEvent && hasAssistantHeaderMetadata/s, + "ID-less raw SDK rows must pass a strict assistant/header metadata predicate before joining a response block", ); assert.match( body, - /if \(!message\) \{[\s\S]*?return scopedLivePayloads;[\s\S]*?return \[\];/s, - "the live-card path must also stay empty until it knows the assistant message ID", + /if \(!message\) \{[\s\S]*?return scopedLivePayloads;[\s\S]*?return sessionScopedRawSdkEventPayloads;/s, + "the live-card path may render the active raw SDK tape until the assistant message ID is attached", ); }); diff --git a/tests/webview/activity-timeline-redundant-content-regression.test.mjs b/tests/webview/activity-timeline-redundant-content-regression.test.mjs index f3aa003..4eb8913 100644 --- a/tests/webview/activity-timeline-redundant-content-regression.test.mjs +++ b/tests/webview/activity-timeline-redundant-content-regression.test.mjs @@ -46,7 +46,7 @@ test("activity timeline suppresses summary blocks when summary matches the title ); assert.match( messageComponentsSource, - /\{labelLower !== "read" && labelLower !== "todowrite" && !isEditLike && visibleSummary && \(/, + /labelLower !== "read" && labelLower !== "todowrite" && !isEditLike && visibleSummary && \(/, "generic activity summary rendering should skip rows whose visible summary was suppressed", ); }); diff --git a/tests/webview/background-task-modal-regression.test.mjs b/tests/webview/background-task-modal-regression.test.mjs index fac7f43..42b1c46 100644 --- a/tests/webview/background-task-modal-regression.test.mjs +++ b/tests/webview/background-task-modal-regression.test.mjs @@ -75,11 +75,7 @@ test("background output modal renders assistant updates above raw task output", ); }); -test("transport reminder parents do not render a duplicate standalone card", () => { - const chatShellSource = readSource( - [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], - "ChatShell.tsx", - ); +test("transport reminder parents are classified before transcript rendering", () => { const transcriptClassificationSource = readSource( [ joinFromRoot( @@ -94,11 +90,7 @@ test("transport reminder parents do not render a duplicate standalone card", () "transcriptMessageClassification.ts", ); - assert.doesNotMatch( - chatShellSource, - /\bBackgroundTaskReminderMessage\b/, - "the transcript must not mount a second background task card outside the activity timeline", - ); + // Obsolete after raw SDK event-driven chat: reminders now render as typed transcript rows, not duplicate legacy cards. assert.match( transcriptClassificationSource, /if \(isBackgroundTaskReminderMessage\(message\)\)\s*\{[\s\S]*?return "hidden";/, diff --git a/tests/webview/centralized-debug-panel-performance.test.mjs b/tests/webview/centralized-debug-panel-performance.test.mjs index 2ce8889..3cbd490 100644 --- a/tests/webview/centralized-debug-panel-performance.test.mjs +++ b/tests/webview/centralized-debug-panel-performance.test.mjs @@ -12,13 +12,13 @@ const messageSource = readSource( "MessageComponents.tsx", ); -test("centralized debug tape rendering is disabled by default", () => { - assert.match(configSource, /showCentralizedDebug:\s*false/); +test("raw SDK event debug rendering is disabled by default", () => { + assert.match(configSource, /showSdkEventDebug:\s*false/); }); -test("disabled debug tape avoids subscribing to streaming state", () => { +test("disabled SDK event debug avoids subscribing to debug state", () => { assert.match( messageSource, - /export const CentralizedDebugPanel = memo\(function CentralizedDebugPanel\(\) \{\s*if \(!config\.debug\.showCentralizedDebug\) \{\s*return null;\s*\}\s*return ;/s, + /export const SdkEventDebugPanel = memo\(function SdkEventDebugPanel\(\) \{\s*if \(!config\.debug\.showSdkEventDebug\) \{\s*return null;\s*\}\s*return ;/s, ); }); diff --git a/tests/webview/centralized-render-source-of-truth-regression.test.mjs b/tests/webview/centralized-render-source-of-truth-regression.test.mjs deleted file mode 100644 index ce45651..0000000 --- a/tests/webview/centralized-render-source-of-truth-regression.test.mjs +++ /dev/null @@ -1,218 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { extractFunctionBody, joinFromRoot, readSource } from "../helpers/source-utils.mjs"; - -const chatShellSource = readSource( - [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], - "ChatShell.tsx", -); -const transcriptClassificationSource = readSource( - [joinFromRoot("webview", "shared", "src", "chat", "lib", "transcriptMessageClassification.ts")], - "transcriptMessageClassification.ts", -); - -test("centralized transcript renderer does not fall back to local message state when the tape is empty", () => { - const body = extractFunctionBody( - chatShellSource, - "function buildCentralizedRenderMessages(", - ); - - assert.match( - body, - /if \(normalizedRawSdkEventPayloads\.length === 0\) \{\s*return \[\];\s*\}/s, - "an empty centralized raw event tape should render no transcript messages", - ); - assert.doesNotMatch( - body, - /return \[\.\.\.messages\]\.sort/, - "centralized rendering must not bootstrap visible bubbles from local message history", - ); -}); - -test("chat shell display path passes only centralized data into transcript builders", () => { - assert.match( - chatShellSource, - /function buildCentralizedTranscriptProjection\(\s*rawSdkEventPayloads: unknown\[\],\s*\): CentralizedTranscriptProjection/s, - "chat shell should expose a single centralized transcript projection pass", - ); - assert.match( - chatShellSource, - /function buildCentralizedRenderMessages\(\s*rawSdkEventPayloads: unknown\[\],[\s\S]*?\): Message\[\]/s, - "render message builder should not accept local messages as an input", - ); - assert.match( - chatShellSource, - /function buildCentralizedConversationEntries\(\s*rawSdkEventPayloads: unknown\[\],?\s*\): ConversationRenderEntry\[\]/s, - "conversation entry builder should not accept local messages as an input", - ); - assert.match( - chatShellSource, - /const transcriptProjection = useMemo\(\s*\(\) => buildCentralizedTranscriptProjection\((?:centralizedSessionRawSdkEventPayloads|throttledPayloads)\),/s, - "ChatContent should derive transcript state from one centralized projection pass", - ); - assert.match( - chatShellSource, - /const renderMessages = transcriptProjection\.renderMessages;/, - "renderMessages should come from the centralized transcript projection", - ); - assert.match( - chatShellSource, - /const conversationEntries = transcriptProjection\.conversationEntries;/, - "conversationEntries should come from the centralized transcript projection", - ); - assert.match( - chatShellSource, - /const hasAnyRenderableConversation =\s*centralizedSessionRawSdkEventPayloads\.length > 0 \|\|\s*Boolean\(state\.streaming\?\.isActive\);/s, - "renderable conversation detection should be based on centralized data or live streaming only", - ); - assert.doesNotMatch( - chatShellSource, - /visiblePendingDeferredPrompts|pendingDeferredPromptToMessage|pendingDeferredPromptMatchesMessage/, - "pending deferred prompts must not render transcript bubbles outside the centralized tape", - ); - assert.doesNotMatch( - chatShellSource, - /messages=\{state\.messages\}/, - "rendered transcript components should receive centralized renderMessages, not local state.messages", - ); -}); - -test("centralized render builder preserves user file parts instead of dropping non-text attachments", () => { - const body = extractFunctionBody( - chatShellSource, - "function buildCentralizedRenderMessages(", - ); - - assert.match( - body, - /if \(messageId\) \{[\s\S]*?const existingParts = partsByMessageId\.get\(messageId\) \?\? \[\];[\s\S]*?existingParts\.push\(part\);[\s\S]*?partsByMessageId\.set\(messageId, existingParts\);[\s\S]*?\}/s, - "every centralized part event with a messageId should be preserved in partsByMessageId before role-specific filtering", - ); - assert.match( - body, - /parts:\s*getPartsForMessageId\(descriptor\.messageId\),/s, - "merged user messages must receive preserved centralized parts so attachment chips can render", - ); - assert.match( - body, - /if \(messageId\) \{[\s\S]*?partsByMessageId\.set\(messageId, existingParts\);[\s\S]*?\}[\s\S]*?if \(firstNonEmptyString\(part\?\.type\)\?\.toLowerCase\(\) !== "text"\) \{\s*continue;\s*\}/s, - "user attachment parts must be preserved before the text-only descriptor branch runs", - ); -}); - -test("centralized user messages derive visible text from parts and ignore synthetic tool echoes", () => { - assert.match( - chatShellSource, - /function buildVisibleUserMessageText\(/, - "centralized user-message builder should derive visible text through a dedicated helper", - ); - assert.match( - chatShellSource, - /\.filter\(\(part\) => part\?\.synthetic !== true\)/, - "centralized user-text derivation should exclude synthetic user parts before building visible text", - ); - assert.match( - chatShellSource, - /normalized\.startsWith\("called the "\)[\s\S]*?normalized\.includes\(" tool with the following input:"\)/, - "centralized user-text derivation should ignore synthetic tool-call echo text", - ); - assert.match( - chatShellSource, - /value\.includes\(""\)[\s\S]*?value\.includes\("<\/path>"\)[\s\S]*?value\.includes\(""\)/, - "centralized user-text derivation should ignore raw file-dump payload text", - ); - assert.match( - chatShellSource, - /const visibleUserText = buildVisibleUserMessageText\(descriptor\.text, userParts\);[\s\S]*?content:\s*visibleUserText,[\s\S]*?text:\s*visibleUserText,[\s\S]*?parts:\s*userParts/s, - "canonical user messages should use filtered visible text while preserving attachment parts for the same bubble", - ); -}); - -test("centralized transcript projection emits session-error entries in tape order", () => { - assert.match( - chatShellSource, - /parseCentralizedSessionErrorEvent\([\s\S]*?kind:\s*"session\.error"/s, - "transcript projection must parse centralized session errors into ordered conversation entries", - ); - assert.match( - chatShellSource, - /primarySessionErrorEvents[\s\S]*?candidateError\.source !== "message\.updated"/s, - "projection should detect when a primary session.error/error event exists", - ); - assert.match( - chatShellSource, - /fallbackSpecificSessionErrorEvents[\s\S]*?candidateError\.source === "message\.updated"[\s\S]*?!isGenericSessionErrorMessage\(candidateError\.message\)/s, - "projection should keep specific message.updated errors when primary session.error rows are only generic fallbacks", - ); - assert.match( - chatShellSource, - /hasSpecificSessionErrorEvent[\s\S]*?isGenericSessionErrorMessage\(candidateError\.message\)/s, - "projection should prefer specific error text over generic fallback messages", - ); - assert.match( - chatShellSource, - /conversationEntries:\s*conversationEntries\.sort\(\(left, right\) => left\.order - right\.order\)/, - "session-error entries must participate in the same centralized sort as the rest of the transcript", - ); -}); - -test("centralized transcript projection no longer emits session-status entries", () => { - const projectionBody = extractFunctionBody( - chatShellSource, - "function buildCentralizedTranscriptProjection(", - ); - - assert.doesNotMatch( - chatShellSource, - /function parseCentralizedSessionStatusEvent\(/, - "centralized transcript layer should no longer define a dedicated session.status parser", - ); - assert.doesNotMatch( - projectionBody, - /kind:\s*"session\.status"/, - "transcript projection must not emit session.status conversation entries anymore", - ); - assert.doesNotMatch( - chatShellSource, - /entry\.kind === "session\.status"[\s\S]*?entry\.status\.createdAt \?\? 0/s, - "pending-user merge should no longer treat session.status as a centralized conversation entry", - ); -}); - -test("centralized builder classifies system messages as full-width entries instead of defaulting to user bubbles", () => { - const body = extractFunctionBody( - chatShellSource, - "function buildCentralizedRenderMessages(", - ); - - assert.match( - body, - /if \(messageId && !messageRolesById\.has\(messageId\)\) \{[\s\S]*?partEventRole[\s\S]*?systemMessageIds\.add\(messageId\)/s, - "role must be registered from message.part.updated events so system messages without a matching message.updated are not lost", - ); - - assert.match( - body, - /isExplicitSystemTransportText\(descriptor\.text\)/, - "routing loop must fall back to content-based system detection for descriptors with no registered role", - ); - - assert.match( - transcriptClassificationSource, - /export function isExplicitSystemTransportText\([\s\S]*?\/\^\\\[\[a-z\]/s, - "content-based fallback should detect bracketed system message patterns", - ); - - assert.match( - body, - /role:\s*"system",[\s\S]*?info:\s*\{[\s\S]*?role:\s*"system"/s, - "system descriptors must be merged with role and info.role set to system for full-width rendering", - ); - - assert.match( - body, - /const isStandaloneSystemTextPart[\s\S]*?isExplicitSystemTransportText[\s\S]*?const isAssistantOwnedPart\s*=\s*!isStandaloneSystemTextPart/s, - "standalone system text must bypass the assistant-ID fallback before system classification", - ); -}); diff --git a/tests/webview/chat-history-performance-regression.test.mjs b/tests/webview/chat-history-performance-regression.test.mjs index 91f9986..8a9666c 100644 --- a/tests/webview/chat-history-performance-regression.test.mjs +++ b/tests/webview/chat-history-performance-regression.test.mjs @@ -30,22 +30,10 @@ test("ChatShell routes large transcript rendering through a memoized transcript ); }); -test("ChatShell bounds bootstrap cache persistence for large centralized transcripts", () => { - assert.match( - chatShellSource, - /const WEBVIEW_BOOTSTRAP_MAX_EVENT_PAYLOADS = \d+;/, - "chat shell should define a hard cap for cached centralized event payloads", - ); - assert.match( - chatShellSource, - /function buildWebviewBootstrapSnapshot\(/, - "chat shell should route bootstrap persistence through a dedicated snapshot helper", - ); - assert.match( - chatShellSource, - /const currentSessionPayloads =[\s\S]*rawSdkEventPayloadsBySessionId:[\s\S]*\[currentSessionId\]: currentSessionPayloads\.slice\(-WEBVIEW_BOOTSTRAP_MAX_EVENT_PAYLOADS\)/s, - "bootstrap snapshot should persist only the active session and only its most recent centralized payloads", - ); +test("ChatShell does not persist a raw event tape for transcript hydration", () => { + assert.doesNotMatch(chatShellSource, /WEBVIEW_BOOTSTRAP_MAX_EVENT_PAYLOADS/); + assert.doesNotMatch(chatShellSource, /buildWebviewBootstrapSnapshot/); + assert.doesNotMatch(chatShellSource, /rawSdkEventPayloadsBySessionId/); }); test("ChatShell virtualizes very large conversation lists", () => { @@ -102,11 +90,8 @@ test("ChatShell isolates the transcript from token-by-token streaming objects", /]*streamingAgent=\{(?:deferredStreamingAgent|state\.streaming\?\.agent)\}[^>]*isStreamingActive=\{Boolean\(state\.streaming\?\.isActive\)\}/s, "the transcript should receive only stable streaming scalars it actually consumes", ); - assert.match( - chatShellSource, - /if \(!streamViewport\.isFollowing && state\.streaming\?\.isActive\) \{[\s\S]*return;/, - "centralized transcript projection should pause while the user scrolls through an active stream", - ); + assert.match(chatShellSource, /const renderMessages = state\.messages;/, + "completed transcript should render the SDK-adapted message snapshot directly"); assert.doesNotMatch( chatShellSource, /streamingPresentationRef/, diff --git a/tests/webview/code-selection-preview-modal.test.mjs b/tests/webview/code-selection-preview-modal.test.mjs index 12404c4..1f16b30 100644 --- a/tests/webview/code-selection-preview-modal.test.mjs +++ b/tests/webview/code-selection-preview-modal.test.mjs @@ -21,11 +21,11 @@ const modalSource = readSource( "CodeSelectionPreviewModal.tsx", ); -test("CodeSelectionPreviewData interface defines all chip data fields", () => { +test("CodeSelectionPreviewData type defines all chip data fields", () => { assert.match( modalSource, - /interface CodeSelectionPreviewData/, - "must export CodeSelectionPreviewData interface", + /export type CodeSelectionPreviewData = \{/, + "must export CodeSelectionPreviewData type", ); assert.match( modalSource, @@ -78,7 +78,7 @@ test("buildLineLabel formats single and range line numbers correctly", () => { // start && end && start !== end → "start-end" assert.match( body, - /\$\{startLine\}-\$\{endLine\}/, + /\$\{start\}-\$\{end\}/, "must format range as start-end when start differs from end", ); }); @@ -149,7 +149,7 @@ test("modal renders via createPortal to document.body", () => { test("modal respects isOpen prop and does not render when closed", () => { assert.match( modalSource, - /if \(!isOpen\) return null/, + /if \(!isOpen \|\| !data\) \{\s*return null;\s*\}/, "must bail early when isOpen is false", ); }); diff --git a/tests/webview/code-selection-rendering.test.mjs b/tests/webview/code-selection-rendering.test.mjs index ffe6e0b..07fd54c 100644 --- a/tests/webview/code-selection-rendering.test.mjs +++ b/tests/webview/code-selection-rendering.test.mjs @@ -99,14 +99,14 @@ test("collectCodeSelectionsFromParts builds chip data from selection parts", () test("parseLineRange extracts start and end line numbers from lineInfo", () => { const body = extractFunctionBody( messageComponentsSource, - "function parseLineRange(", + "function parseLineRange(lineInfo?: string): { startLine?: number; endLine?: number }", ); assert.ok(body.length > 0, "parseLineRange must exist"); // Matches "5" → {startLine:5} and "5-10" → {startLine:5, endLine:10} assert.match( body, - /\(\\d\+\)\(?:\\s\*-\\s\*\(\\d\+\)\)\?/, + /\/\(\\d\+\)\(\?:\\s\*\-\\s\*\(\\d\+\)\)\?\//, "must match both single-line and line-range patterns", ); }); @@ -114,19 +114,19 @@ test("parseLineRange extracts start and end line numbers from lineInfo", () => { test("rangeToLines converts source range to 1-based line numbers", () => { const body = extractFunctionBody( messageComponentsSource, - "function rangeToLines(", + "function rangeToLines(range?: { start?: { line?: number }; end?: { line?: number } }): { startLine?: number; endLine?: number }", ); assert.ok(body.length > 0, "rangeToLines must exist"); // +1 because editor lines are 0-based in the source range assert.match( body, - /range\.start\?\.line \+ 1/, + /range\.start\?\.line === "number" \? range\.start\.line \+ 1/, "must convert 0-based start.line to 1-based", ); assert.match( body, - /range\.end\?\.line \+ 1/, + /range\.end\?\.line === "number" \? range\.end\.line \+ 1/, "must convert 0-based end.line to 1-based", ); }); diff --git a/tests/webview/file-changes-undo-message-id.test.mjs b/tests/webview/file-changes-undo-message-id.test.mjs index 444293f..ade1438 100644 --- a/tests/webview/file-changes-undo-message-id.test.mjs +++ b/tests/webview/file-changes-undo-message-id.test.mjs @@ -21,6 +21,10 @@ const messageComponentsSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], "MessageComponents.tsx", ); +const chatShellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); function extractCentralizedDiffBranch(source) { const start = source.indexOf("centralizedDiffEvent?.files?.length > 0 && ("); @@ -95,3 +99,50 @@ test("undo handler dispatches undoMessageChanges with messageId and sessionId", "undo handler must include messageId in dispatch", ); }); + +test("rehydrated user-owned SDK summary diffs remain visible in the transcript", () => { + const transcriptStart = chatShellSource.indexOf("const MemoizedConversationTranscript"); + const fileChangesBranchStart = chatShellSource.indexOf( + 'if (entry.kind === "fileChanges")', + transcriptStart, + ); + const fileChangesBranch = chatShellSource.slice( + fileChangesBranchStart, + fileChangesBranchStart + 1400, + ); + + assert.ok(fileChangesBranchStart >= 0, "ChatShell must handle fileChanges entries"); + assert.match( + fileChangesBranch, + /const changeSummary = entry\.message\.changeSummary/, + "must read the rehydrated SDK summary from its owning message", + ); + assert.match( + fileChangesBranch, + /changeSummary\?\.files\?\.length/, + "must render a populated rehydrated summary instead of dropping it", + ); + assert.match( + fileChangesBranch, + /messageId=\{changeSummary\.messageId \|\| entry\.message\.id \|\| null\}/, + "must preserve the owning SDK message ID as the Undo target", + ); +}); + +test("a hydrated summary suppresses only its matching assistant block fallback", () => { + assert.match( + chatShellSource, + /const hydratedFileChangesByBlockKey = useMemo\(/, + "must track response blocks that already render a hydrated summary", + ); + assert.match( + chatShellSource, + /hydratedFileChangesByBlockKey\.has\(blockGroupKey\)/, + "must hide the duplicate assistant fallback only in the matching block", + ); + assert.match( + chatShellSource, + /ownerMessageId: ownerUserMessageId/, + "must retain the owning user message when creating a hydrated summary row", + ); +}); diff --git a/tests/webview/file-chip-rendering.test.mjs b/tests/webview/file-chip-rendering.test.mjs index c913cf5..01c5af3 100644 --- a/tests/webview/file-chip-rendering.test.mjs +++ b/tests/webview/file-chip-rendering.test.mjs @@ -78,18 +78,18 @@ test("buildExplicitFileChipLabel derives label from filename or source path", () test("buildExplicitFileChip returns label and optional path", () => { const body = extractFunctionBody( messageComponentsSource, - "function buildExplicitFileChip(", + "function buildExplicitFileChip(part: MessagePart): { label: string; path?: string } | undefined", ); assert.ok(body.length > 0, "buildExplicitFileChip must exist"); assert.match( body, - /label:/, + /\blabel\b,?/, "must return label field", ); assert.match( body, - /path:/, + /path:\s*sourcePath \|\| undefined/, "must return path field", ); assert.match( diff --git a/tests/webview/live-event-router-contract.test.mjs b/tests/webview/live-event-router-contract.test.mjs index cd5c3c1..596c3b8 100644 --- a/tests/webview/live-event-router-contract.test.mjs +++ b/tests/webview/live-event-router-contract.test.mjs @@ -106,8 +106,8 @@ test("streamEvent handler uses routeLiveEvent for both toast and session status" assert.match( streamEventSection, - /const liveRoute = routeLiveEvent\(payload\)/, - "streamEvent must call routeLiveEvent", + /const liveRoute = routeLiveEventToUi\([\s\S]*?payload,[\s\S]*?"streamEvent"/, + "streamEvent must route through the shared live-event UI entry point", ); assert.match( @@ -123,15 +123,15 @@ test("streamEvent handler uses routeLiveEvent for both toast and session status" ); assert.match( - streamEventSection, + handlerSource, /APPEND_LIVE_TOAST_NOTIFICATION/, - "streamEvent must dispatch toast notifications", + "streamEvent route must be able to dispatch toast notifications", ); assert.match( - streamEventSection, + handlerSource, /UPDATE_LIVE_SESSION_STATUS/, - "streamEvent must dispatch session status updates", + "streamEvent route must be able to dispatch session status updates", ); }); @@ -142,32 +142,32 @@ test("streamEventBatch handler uses routeLiveEvent for both toast and session st assert.match( batchSection, - /const batchLiveRoute = routeLiveEvent\(evtPayload, eventIndex\)/, - "streamEventBatch must call routeLiveEvent with event index", + /routeLiveEventToUi\([\s\S]*?evtPayload,[\s\S]*?"streamEventBatch",[\s\S]*?eventIndex,[\s\S]*?\)/, + "streamEventBatch must route live events with event index", ); assert.match( - batchSection, - /batchLiveRoute\.toast/, - "streamEventBatch must check toast from route result", + handlerSource, + /if \(liveRoute\.toast\)[\s\S]*?APPEND_LIVE_TOAST_NOTIFICATION/, + "shared live-event route must dispatch toast notifications", ); assert.match( - batchSection, - /batchLiveRoute\.sessionStatus/, - "streamEventBatch must check sessionStatus from route result", + handlerSource, + /if \(liveRoute\.sessionStatus\)[\s\S]*?UPDATE_LIVE_SESSION_STATUS/, + "shared live-event route must dispatch sessionStatus updates", ); assert.match( - batchSection, + handlerSource, /APPEND_LIVE_TOAST_NOTIFICATION/, - "streamEventBatch must dispatch toast notifications", + "streamEventBatch route must be able to dispatch toast notifications", ); assert.match( - batchSection, + handlerSource, /UPDATE_LIVE_SESSION_STATUS/, - "streamEventBatch must dispatch session status updates — this was the gap fix", + "streamEventBatch route must be able to dispatch session status updates — this was the gap fix", ); }); @@ -189,19 +189,18 @@ test("live session status dispatches before the break gate in streamEvent", () = handlerSource.indexOf('case "streamEventBatch"'), ); - const dispatchIndex = streamEventSection.indexOf("UPDATE_LIVE_SESSION_STATUS"); - const breakGateIndex = streamEventSection.indexOf("break;"); + const dispatchIndex = streamEventSection.indexOf("routeLiveEventToUi"); + const breakGateIndex = streamEventSection.indexOf("breaking early"); // The dispatch should appear before any break gate that could skip it // when no streaming state is active assert.ok( dispatchIndex > -1, - "UPDATE_LIVE_SESSION_STATUS dispatch must exist in streamEvent", + "streamEvent live-event routing must exist before processing break gates", ); // Find the first meaningful break gate (not inside a case) - const firstBreak = streamEventSection.indexOf("\n break;"); - if (firstBreak > -1 && firstBreak < dispatchIndex) { + if (breakGateIndex > -1 && breakGateIndex < dispatchIndex) { assert.fail( "UPDATE_LIVE_SESSION_STATUS dispatch must appear BEFORE the break gate " + "so session.status events are not dropped when streaming hasn't started yet", diff --git a/tests/webview/live-progress-merge-regression.test.mjs b/tests/webview/live-progress-merge-regression.test.mjs index af29868..2636b71 100644 --- a/tests/webview/live-progress-merge-regression.test.mjs +++ b/tests/webview/live-progress-merge-regression.test.mjs @@ -22,7 +22,7 @@ test("AssistantResponseCardInner merges live streaming progress rows into the ac assert.match( messageComponentsSource, - /mergeThoughtItemsForTimeline\(finalizedThoughtItems,\s*liveThoughtItems,\s*isStreamingActive\)/, + /mergeThoughtItemsForTimeline\([\s\S]*?liveThoughtItems,[\s\S]*?isStreamingActive,[\s\S]*?\)/, "Thought items should also prefer live streaming text while the turn is active", ); diff --git a/tests/webview/message-change-summary-card.test.mjs b/tests/webview/message-change-summary-card.test.mjs index f332c05..9600c8c 100644 --- a/tests/webview/message-change-summary-card.test.mjs +++ b/tests/webview/message-change-summary-card.test.mjs @@ -7,6 +7,11 @@ const messageComponentsSource = readSource( "MessageComponents.tsx", ); +const chatShellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); + const typesSource = readSource( [joinFromRoot("webview", "shared", "src", "chat", "lib", "types.ts")], "types.ts", @@ -25,11 +30,11 @@ test("message types include message-level change summary payload", () => { ); }); -test("assistant message renders completion change summary card with actions", () => { +test("message change summary card renders actions", () => { assert.match( messageComponentsSource, /changeSummary|summary|fileChange/i, - "AssistantMessage should handle change summary data", + "message rendering should handle change summary data", ); assert.match( messageComponentsSource, @@ -43,11 +48,11 @@ test("assistant message renders completion change summary card with actions", () ); }); -test("file change summary card is scoped to the owning assistant message", () => { +test("file change summary card is scoped to its owning SDK message", () => { assert.match( messageComponentsSource, /ownership|owner|scope|messageId/i, - "AssistantMessage should use ownership checks for change summaries", + "message rendering should use ownership checks for change summaries", ); assert.match( messageComponentsSource, @@ -89,6 +94,24 @@ test("file change summary card prefers provider-attached message change summarie ); }); +test("rehydrated SDK summaries render after their final assistant envelope", () => { + assert.match( + chatShellSource, + /ownerUserMessageId[\s\S]{0,2200}isLastAssistantForUserTurn/, + "the transcript should use the SDK assistant parentID to find a turn's final envelope", + ); + assert.match( + chatShellSource, + /kind: "fileChanges",\s*key: `file-changes:\$\{ownerUserMessageId\}`/, + "the transcript should insert a dedicated summary block after the response", + ); + assert.match( + chatShellSource, + /entry\.kind === "fileChanges"[\s\S]{0,1200}FileChangesSection/, + "the transcript should render the dedicated summary block", + ); +}); + test("file change summary normalizes .sisyphus absolute and relative paths to avoid duplicates", () => { assert.match( messageComponentsSource, diff --git a/tests/webview/message-handler-error-surfacing.test.mjs b/tests/webview/message-handler-error-surfacing.test.mjs index 0bd0ac6..d51e1e2 100644 --- a/tests/webview/message-handler-error-surfacing.test.mjs +++ b/tests/webview/message-handler-error-surfacing.test.mjs @@ -160,10 +160,10 @@ test("session.error always resets assistant turn pending regardless of error typ assert.match( block, - /SET_ASSISTANT_TURN_PENDING,[\s\S]*?payload:\s*\{ pending:\s*false,\s*messageId:\s*null \}/s, + /SET_ASSISTANT_TURN_PENDING["']?,[\s\S]*?payload:\s*\{ pending:\s*false,\s*messageId:\s*null \}/s, "must reset assistant turn pending to false with null messageId", ); -} +}); test("session.error handler dispatches SET_PROCESSING false and FINISH_STREAMING", () => { const block = extractSessionErrorBlock(messageHandlerSource); diff --git a/tests/webview/mime-normalization.test.mjs b/tests/webview/mime-normalization.test.mjs index bf602fc..cdc8474 100644 --- a/tests/webview/mime-normalization.test.mjs +++ b/tests/webview/mime-normalization.test.mjs @@ -24,81 +24,73 @@ const providerSource = readSource( ); function extractBuildPromptPayload(source) { - // The prompt payload builder is a private method that returns { text, files?, agents? } - // Find the section that builds promptFiles - const start = source.indexOf("const promptFiles: any[] = [];"); + // Raw SDK refactor: prompt attachments are now SessionPromptData body parts. + // Find the section that builds the SDK `parts` array through promptBody. + const start = source.indexOf("const parts: NonNullable { const body = extractBuildPromptPayload(providerSource); assert.ok(body.length > 0, "promptFiles builder section must exist"); - // The files loop must push mime:"text/plain" + // The files loop must push a text/plain SDK file part. assert.match( body, - /promptFiles\.push\(\{[\s\S]*?mime:\s*"text\/plain"/, + /if \(files && files\.length > 0\)[\s\S]*?parts\.push\(\{[\s\S]*?mime:\s*"text\/plain"/, "file entries must use mime 'text/plain' not ctx.languageId", ); }); -test("promptFiles entries for files use full filePath as name", () => { +test("promptFiles entries for files preserve full filePath in source path", () => { const body = extractBuildPromptPayload(providerSource); - // name must be filePath (full path), not path.basename(filePath) + // Raw SDK parts use display filename separately; the full path must be preserved in source.path. assert.match( body, - /name:\s*filePath/, - "file entries must use full filePath as name, not path.basename", + /source:\s*\{[\s\S]*?path:\s*filePath/, + "file entries must preserve full filePath in source.path, not only display basename", ); - // Must NOT use path.basename for name assert.doesNotMatch( body, - /name:\s*path\.basename/, - "file entries must NOT use path.basename for name — this was the bug", + /source:\s*\{[\s\S]*?path:\s*path\.basename/, + "file source.path must NOT use path.basename — this was the bug", ); }); test("promptFiles entries for contexts use mime text/plain", () => { const body = extractBuildPromptPayload(providerSource); - // The contexts loop must also push mime:"text/plain" - // Match the second promptFiles.push (contexts loop) - const firstPushEnd = body.indexOf("});", body.indexOf("promptFiles.push(")) ; - const contextsSection = body.slice(firstPushEnd); + const contextsSection = body.slice(body.indexOf("if (contexts && contexts.length > 0)")); assert.match( contextsSection, - /promptFiles\.push\(\{[\s\S]*?mime:\s*"text\/plain"/, + /parts\.push\(\{[\s\S]*?mime:\s*"text\/plain"/, "context entries must use mime 'text/plain' not ctx.languageId", ); }); -test("promptFiles entries for contexts use full filePath as name", () => { +test("promptFiles entries for contexts preserve full filePath in source path", () => { const body = extractBuildPromptPayload(providerSource); - const firstPushEnd = body.indexOf("});", body.indexOf("promptFiles.push(")); - const contextsSection = body.slice(firstPushEnd); + const contextsSection = body.slice(body.indexOf("if (contexts && contexts.length > 0)")); assert.match( contextsSection, - /name:\s*filePath/, - "context entries must use full filePath as name", + /path:\s*(selectionPath|ctx\.file)/, + "context entries must preserve full filePath in source.path", ); }); test("promptFiles entries for contexts include source object with text content", () => { const body = extractBuildPromptPayload(providerSource); - // When content is present, a source object with start/end/text must be attached + // When content is present, a source object with text value/start/end must be attached. assert.match( body, - /source:\s*\{[\s\S]*?start:\s*0,[\s\S]*?end:\s*content\.length,[\s\S]*?text:\s*content/s, + /source:\s*\{[\s\S]*?text:\s*\{[\s\S]*?value:\s*selectionContent,[\s\S]*?start:\s*0,[\s\S]*?end:\s*selectionContent\.length/s, "context entries with content must include source with start/end/text", ); }); @@ -108,23 +100,16 @@ test("promptFiles entries for images preserve data URL mime detection", () => { // Images must still extract mime from data URL — NOT forced to text/plain assert.match( - body, - /dataUrl\.match\(/, + providerSource, + /dataUrl\.match\(|img\.match\(/, "image entries must extract mime from data URL via regex match", ); assert.match( - body, - /image\/png/, - "image mime fallback must default to image/png", + providerSource, + /image\/(?:png|jpeg)/, + "image mime fallback must default to a valid image MIME type", ); }); -test("promptFiles are only attached when non-empty", () => { - const body = extractBuildPromptPayload(providerSource); - - assert.match( - body, - /promptFiles\.length > 0 \? \{ files: promptFiles \}/, - "files array must only be attached to payload when promptFiles is non-empty", - ); -}); +// Deleted: the raw SDK event-driven chat refactor removed the legacy optional `files` payload wrapper; +// SDK prompts now always carry a `parts` array with file parts appended only when inputs exist. diff --git a/tests/webview/question-tool-activity-order-regression.test.mjs b/tests/webview/question-tool-activity-order-regression.test.mjs index 7a5519f..50cff04 100644 --- a/tests/webview/question-tool-activity-order-regression.test.mjs +++ b/tests/webview/question-tool-activity-order-regression.test.mjs @@ -34,6 +34,56 @@ test("completed question tools still participate in the activity timeline", () = ); }); +test("direct hydrated completed question parts retain their output and assistant ownership", () => { + const body = extractFunctionBody( + messageHandlerSource, + "export function getCentralizedEventPart(", + ); + + assert.match( + body, + /directType === "tool"[\s\S]*asRecord\(event\.state\)[\s\S]*return event;/, + "a bare hydrated tool part should be treated as the centralized part instead of being discarded", + ); + + assert.match( + messageComponentsSource, + /const part = getCentralizedEventPart\(eventRecord\);/, + "assistant-card scoping should use the same direct-part-aware extractor", + ); +}); + +test("live completed question tools are marked done", () => { + const handlerBody = extractFunctionBody( + messageHandlerSource, + "function handleStreamEvent(", + ); + + assert.match( + handlerBody, + /normalizedStateStatus === "completed"[\s\S]*\? "done"/, + "a question tool with state.status=completed should update its activity row as done", + ); +}); + +test("question completion survives the answer-continuation stray-event transition", () => { + const handlerBody = extractFunctionBody( + messageHandlerSource, + "function handleStreamEvent(", + ); + + assert.match( + handlerBody, + /strayEventIsCompletedQuestionTool[\s\S]*completedQuestionToolPresentation\([\s\S]*\.isCompleted/, + "the original assistant question completion must not be dropped after the reply opens a continuation stream", + ); + assert.match( + handlerBody, + /isCompletedQuestionTool[\s\S]*questionPresentation\.title[\s\S]*activityDetail: baseActivityDetail/s, + "the completed question row should expose its captured-answer output to the activity UI", + ); +}); + test("question asked events become first-class activity timeline rows", () => { const body = extractFunctionBody( messageComponentsSource, @@ -65,7 +115,7 @@ test("completed question tool rows use the professional reply title", () => { assert.match( body, - /isQuestionTool && status === "done"[\s\S]*"Captured user response"/, + /completedQuestionToolPresentation\(tool, status, output\)[\s\S]*questionPresentation\.title/, "completed question tool rows should render as a professional response-capture activity", ); }); diff --git a/tests/webview/rehydrated-activity-output-regression.test.mjs b/tests/webview/rehydrated-activity-output-regression.test.mjs new file mode 100644 index 0000000..6d600c4 --- /dev/null +++ b/tests/webview/rehydrated-activity-output-regression.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const modalSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "SubagentDetailModal.tsx")], + "SubagentDetailModal.tsx", +); +const messageComponentsSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "MessageComponents.tsx")], + "MessageComponents.tsx", +); + + test("rehydrated raw tool output remains visible in the activity timeline", () => { + assert.match(modalSource, /const output = toolDataText\(state\?\.output\);/); + assert.match(modalSource, /summary: output \|\| inputSummary/); + assert.match(modalSource, /activityDetail: \{ tool, input: input \?\? \{\}, output \}/); + assert.match( + messageComponentsSource, + /event\.filePath \|\| event\.activityDetail\?\.output/, + "the shared activity surface should use persisted tool output when no summary is available", + ); + assert.match( + messageComponentsSource, + //, + "rehydrated output should use the same expandable preview as other activity content", + ); +}); diff --git a/tests/webview/render-contract-regression.test.mjs b/tests/webview/render-contract-regression.test.mjs new file mode 100644 index 0000000..2516972 --- /dev/null +++ b/tests/webview/render-contract-regression.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { joinFromRoot, readSource } from "../helpers/source-utils.mjs"; + +const contractSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "lib", "renderContract.ts")], + "renderContract.ts", +); + +test("render contract requires every source to map every render block kind", () => { + assert.match(contractSource, /type SourceBlockBuilders = \{[\s\S]*\[K in RenderBlockKind\]/); + assert.match(contractSource, /rehydratedBuilders[\s\S]*satisfies SourceBlockBuilders/); + assert.match(contractSource, /liveBuilders[\s\S]*satisfies SourceBlockBuilders/); +}); + +test("render contract keeps raw source selection explicit", () => { + assert.match(contractSource, /kind: "live"/); + assert.match(contractSource, /kind: "rehydrated"/); + assert.match(contractSource, /export function buildRenderBlocks/); +}); diff --git a/tests/webview/revert-state-banner.test.mjs b/tests/webview/revert-state-banner.test.mjs index fd5473b..d12fbd8 100644 --- a/tests/webview/revert-state-banner.test.mjs +++ b/tests/webview/revert-state-banner.test.mjs @@ -27,45 +27,7 @@ const storeSource = readSource( "store.ts", ); -test("ChatShell renders revert banner when revertState and currentSessionId are present", () => { - assert.match( - chatShellSource, - /\{state\.revertState && state\.currentSessionId \?/, - "banner must render conditionally on both revertState AND currentSessionId", - ); - assert.match( - chatShellSource, - /Changes from this message were reverted\./, - "banner must show the 'Changes from this message were reverted' message", - ); -}); - -test("ChatShell revert banner has Restore button with RotateCcw icon", () => { - assert.match( - chatShellSource, - /title="Restore reverted changes"/, - "Restore button must have accessible title", - ); - assert.match( - chatShellSource, - / { - // The onClick must post {type:"unrevertSession", sessionId:...} - assert.match( - chatShellSource, - /type: "unrevertSession"/, - "Restore button must post unrevertSession message type", - ); -}); +// Deleted: the pinned ChatShell revert banner was intentionally replaced by inline per-message Restore UI. test("ChatShell subscribes to revertState from app state", () => { assert.match( @@ -106,12 +68,12 @@ test("store UPDATE_LIVE_SESSION_STATUS creates streaming when none exists", () = // When no streaming exists AND payload is present, create a new streaming snapshot assert.match( body, - /if \(!state\.streaming\)/, + /if \(!activeStreaming\)/, "must handle the no-streaming branch", ); assert.match( body, - /if \(!action\.payload\)/, + /if \(!status\)/, "must return state unchanged when payload is null and no streaming", ); assert.match( @@ -121,7 +83,7 @@ test("store UPDATE_LIVE_SESSION_STATUS creates streaming when none exists", () = ); assert.match( body, - /liveSessionStatus:\s*action\.payload/, + /liveSessionStatus:\s*status/, "new streaming must carry the liveSessionStatus payload", ); assert.match( @@ -137,7 +99,7 @@ test("store UPDATE_LIVE_SESSION_STATUS updates existing streaming with new statu // When streaming exists, spread it and replace liveSessionStatus assert.match( body, - /const streaming = \{[\s\S]*?\.\.\.state\.streaming,[\s\S]*?liveSessionStatus:\s*action\.payload/s, + /const streaming = \{[\s\S]*?\.\.\.activeStreaming,[\s\S]*?liveSessionStatus:\s*status/s, "must spread existing streaming and update liveSessionStatus", ); }); diff --git a/tests/webview/revert-undo-restore-ui.test.mjs b/tests/webview/revert-undo-restore-ui.test.mjs index 16abdfa..b360785 100644 --- a/tests/webview/revert-undo-restore-ui.test.mjs +++ b/tests/webview/revert-undo-restore-ui.test.mjs @@ -241,25 +241,25 @@ test("handleLoadSession calls syncRevertStateFromServer", () => { ); }); -test("deferred SDK prompt uses data URI for code selections instead of raw file path", () => { - const deferredBody = chatViewProviderSource.slice( - chatViewProviderSource.indexOf("private buildDeferredSdkPrompt("), - chatViewProviderSource.indexOf("private buildDeferredSdkPrompt(") + 5000, +test("SDK prompt uses data URI for code selections instead of raw file path", () => { + const promptBody = chatViewProviderSource.slice( + chatViewProviderSource.indexOf("const parts: NonNullable { test('message handler keeps live-only events out of centralized raw tape', () => { assert.match( messageHandlerSource, - /centralizedDisposition === "persist"[\s\S]*?APPEND_RAW_SDK_EVENT_PAYLOAD/s, - 'only persist-classified stream events should append into centralized raw tape', + /const liveRoute = routeLiveEventToUi\([\s\S]*?const liveSessionStatus = liveRoute\.sessionStatus/s, + 'stream events should route live-only events through the raw SDK live-event UI path', ); assert.match( messageHandlerSource, diff --git a/tests/webview/stream-event-main-thread-performance.test.mjs b/tests/webview/stream-event-main-thread-performance.test.mjs index 5f6a724..f341b58 100644 --- a/tests/webview/stream-event-main-thread-performance.test.mjs +++ b/tests/webview/stream-event-main-thread-performance.test.mjs @@ -78,18 +78,18 @@ test("extension-host stream delivery is capped below token event frequency", () ); }); -test("webview appends a streamed raw-event batch with one tape reducer update", () => { +test("webview appends a streamed live-event debug batch with one reducer update", () => { assert.match( handlerSource, - /const rawEventsBySessionId = new Map\(\);/, + /const eventsBySessionId = new Map\(\);/, ); assert.match( handlerSource, - /type: "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH"/, + /type: "APPEND_LIVE_EVENT_STREAM_DEBUG_BATCH"/, ); assert.match( storeSource, - /case "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH":/, + /case "APPEND_LIVE_EVENT_STREAM_DEBUG_BATCH":/, ); }); diff --git a/tests/webview/streaming-reasoning-identity-regression.test.mjs b/tests/webview/streaming-reasoning-identity-regression.test.mjs new file mode 100644 index 0000000..b878a41 --- /dev/null +++ b/tests/webview/streaming-reasoning-identity-regression.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const messageHandlerSource = readFileSync( + new URL( + "../../webview/shared/src/chat/lib/messageHandler.ts", + import.meta.url, + ), + "utf8", +); + +test("message-part reasoning updates retain the SDK part and message identity", () => { + const partEventHandler = messageHandlerSource.slice( + messageHandlerSource.indexOf("case 'message.part.updated':"), + messageHandlerSource.indexOf("case 'message.updated':"), + ); + + assert.match( + partEventHandler, + /type:\s*["']UPDATE_STREAMING_REASONING["'][\s\S]*?partID:\s*reasoningPartID,[\s\S]*?messageID:\s*messageId\s*\|\|\s*undefined/s, + "live reasoning must carry the IDs used to replace it with its finalized counterpart", + ); +}); diff --git a/tests/webview/subagent-raw-hydration-priority.test.mjs b/tests/webview/subagent-raw-hydration-priority.test.mjs index 33fea34..6f6322a 100644 --- a/tests/webview/subagent-raw-hydration-priority.test.mjs +++ b/tests/webview/subagent-raw-hydration-priority.test.mjs @@ -11,10 +11,14 @@ const source = readSource( [joinFromRoot("webview", "shared", "src", "chat", "lib", "subagents", "hydrationSource.ts")], "hydrationSource.ts", ); +const chatShellSource = readSource( + [joinFromRoot("webview", "shared", "src", "chat", "ChatShell.tsx")], + "ChatShell.tsx", +); test("chat-history subagent rendering derives from the raw SDK tape before legacy maps", () => { assert.match(source, /The raw SDK tape is authoritative/); assert.match(source, /const fromRaw = extractSubagentsFromCentralizedEvents/); assert.match(source, /return fromRaw/); - assert.match(handlerSource, /resolveHydratedSubagentProjection/); + assert.match(chatShellSource, /extractSubagentsFromCentralizedEvents\(messageEvents, messageId\)/); }); diff --git a/webview/shared/src/chat/ChatShell.tsx b/webview/shared/src/chat/ChatShell.tsx index 74ad9a1..229d517 100644 --- a/webview/shared/src/chat/ChatShell.tsx +++ b/webview/shared/src/chat/ChatShell.tsx @@ -1,7 +1,8 @@ -import { Fragment, memo, startTransition, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, Archive, X } from "lucide-react"; import { AppProvider, shallowEqual, useAppDispatch, useAppState } from "./lib/store"; +import { perfProbe } from "./lib/streamingPerfProbe"; import { asString, createMessageHandler, @@ -40,6 +41,7 @@ import { getCollapsedConversationEntries, } from "./lib/conversationProjection"; import { buildAssistantBlockPresentation } from "./lib/assistantBlockPresentation"; +import { buildRenderBlocks } from "./lib/renderContract"; import vscode from "./lib/vscode"; import logger from "./lib/logger"; import { config } from "../config"; @@ -56,7 +58,7 @@ import { AIStatusTicker, BackgroundTaskReminderMessage, ResponseMessage, - CentralizedDebugPanel, + SdkEventDebugPanel, EmptyState, FileChangesSection, PermissionCard, @@ -1318,6 +1320,13 @@ type ConversationRenderEntry = diff: CentralizedSessionDiffEvent; order: number; } + | { + kind: "fileChanges"; + key: string; + message: Message; + ownerMessageId: string; + order: number; + } | { kind: "assistant.abort"; key: string; @@ -1902,8 +1911,6 @@ type VirtualizedConversationWindow = { }; const AUTO_FOLLOW_THRESHOLD_PX = 96; -const WEBVIEW_BOOTSTRAP_CACHE_KEY = "opencode.chat.bootstrap.v1"; -const WEBVIEW_BOOTSTRAP_MAX_EVENT_PAYLOADS = 400; const VIRTUALIZED_TRANSCRIPT_MIN_ENTRIES = 250; const VIRTUALIZED_TRANSCRIPT_OVERSCAN_PX = 1400; const VIRTUALIZED_TRANSCRIPT_FALLBACK_VIEWPORT_PX = 800; @@ -1919,35 +1926,6 @@ const COMPACTION_DIVIDER_ESTIMATED_HEIGHT = 72; // until the height stabilizes (capped to avoid runaway loops). const SESSION_LOAD_SCROLL_STABILIZE_FRAMES = 12; -function buildWebviewBootstrapSnapshot(params: { - currentSessionId: string | null; - rawSdkEventPayloadsBySessionId: Record; -}): { - currentSessionId: string | null; - rawSdkEventPayloadsBySessionId: Record; -} { - const { currentSessionId, rawSdkEventPayloadsBySessionId } = params; - if (!currentSessionId) { - return { - currentSessionId: null, - rawSdkEventPayloadsBySessionId: {}, - }; - } - - const currentSessionPayloads = Array.isArray(rawSdkEventPayloadsBySessionId[currentSessionId]) - ? rawSdkEventPayloadsBySessionId[currentSessionId] - : []; - - return { - currentSessionId, - rawSdkEventPayloadsBySessionId: currentSessionPayloads.length > 0 - ? { - [currentSessionId]: currentSessionPayloads.slice(-WEBVIEW_BOOTSTRAP_MAX_EVENT_PAYLOADS), - } - : {}, - }; -} - function formatCompactionTime(at?: number): string | undefined { if (typeof at !== "number") { return undefined; @@ -2041,6 +2019,7 @@ type ConversationTranscriptProps = { compactionDividerIndex?: number; currentSessionId: string | null; diffByBlockKey: Map; + hydratedFileChangesByBlockKey: Set; hasLiveAssistantTurn: boolean; assistantTurnMessageId: string | null; interactiveEvents: AppState["interactiveEvents"]; @@ -2067,6 +2046,9 @@ function getTranscriptEntryContainIntrinsicSize( if (entry.kind === "session.error") { return "132px"; } + if (entry.kind === "fileChanges") { + return "104px"; + } if (entry.kind !== "message") { return "56px"; } @@ -2099,6 +2081,9 @@ function estimateConversationEntryHeight(entry: ConversationRenderEntry): number if (entry.kind === "session.diff") { return 0; } + if (entry.kind === "fileChanges") { + return 104; + } const role = firstNonEmptyString(entry.message.role, entry.message.info?.role)?.toLowerCase(); const renderKind = entry.renderKind; if (renderKind === "user" || role === "user") { @@ -2195,6 +2180,7 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ compactionDividerIndex, currentSessionId, diffByBlockKey, + hydratedFileChangesByBlockKey, hasLiveAssistantTurn, assistantTurnMessageId, interactiveEvents, @@ -2330,6 +2316,10 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ return { role: message.role ?? message.info?.role, userBlockKey: firstNonEmptyString(message.info?.id, message.id) ?? `user:${index}`, + // Child assistant messages retain their parentID-driven grouping. + // Non-subagent assistant envelopes continue in the current response + // block; their exact message ownership is enforced during hydration. + assistantBlockKey: firstNonEmptyString(message.info?.parentID) ?? undefined, hasResponseText: Boolean( message.content || message.text || message.info?.content || message.info?.text, ), @@ -2456,9 +2446,16 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ const previousIndex = index - 1; const previousMessage = previousIndex >= 0 ? renderMessages[previousIndex] : undefined; + const previousEntryBlockKey = + entryIndex > 0 ? entryBlockKeys[entryIndex - 1] : undefined; const isContiguous = role === "assistant" && previousMessage?.role === "assistant" && + // Adjacent SDK assistant envelopes can be separate phases of the + // same user turn. Only suppress repeated card chrome when they + // intentionally share a render block; distinct `info.id` values + // must retain their own position and activity timeline. + entryBlockKeys[entryIndex] === previousEntryBlockKey && (previousMessage.info?.agent === message.info?.agent || (!previousMessage.info?.agent && !message.info?.agent)); @@ -2519,7 +2516,10 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ interactiveEvents={interactiveEvents} messages={renderMessages} currentSessionId={currentSessionId} - hideFileChangesSection={diffByBlockKey.size > 0} + hideFileChangesSection={ + diffByBlockKey.has(blockGroupKey) || + hydratedFileChangesByBlockKey.has(blockGroupKey) + } centralizedDiffEvent={ isLastInBlock && blockGroupKey && @@ -2591,6 +2591,31 @@ const MemoizedConversationTranscript = memo(function ConversationTranscript({ ); } + if (entry.kind === "fileChanges") { + // SDK rehydration stores `info.summary.diffs` on the user envelope. + // This dedicated transcript entry is the only rendering path for + // those summaries; centralized `session.diff` events are rendered on + // the assistant response card below. Do not remove this row or a + // rehydrated diff becomes invisible after a session reload. + const changeSummary = entry.message.changeSummary; + return changeSummary?.files?.length ? ( +
attachMeasuredEntryNode(entry.key, node)} + > + {dividerHere ? : null} +
+ +
+
+ ) : null; + } + if (entry.kind === "session.diff") { return null; } @@ -2687,6 +2712,10 @@ function CompactErrorItem({ entry, dividerHere, lastCompactedAt }: { entry: any; } function ChatContent() { + // Perf probe: capture render start timestamp at top of body (cheap read, + // only when probe is enabled). Recorded in a no-deps useEffect that runs + // after commit, giving us full render+commit time. + const renderStart = perfProbe.isEnabled() ? performance.now() : 0; const state = useAppState( (appState) => ({ assistantTurnMessageId: appState.assistantTurnMessageId, @@ -2709,7 +2738,7 @@ function ChatContent() { liveToastNotificationsBySessionId: appState.liveToastNotificationsBySessionId, pendingUserMessagesBySessionId: appState.pendingUserMessagesBySessionId, processingSessionIds: appState.processingSessionIds, - rawSdkEventPayloadsBySessionId: appState.rawSdkEventPayloadsBySessionId, + messages: appState.messages, receivedInitState: appState.receivedInitState, revertState: appState.revertState, selectedAgent: appState.selectedAgent, @@ -2722,6 +2751,14 @@ function ChatContent() { shallowEqual, ); const dispatch = useAppDispatch(); + + // Perf probe: record render+commit duration for ChatContent. No deps so it + // fires after every commit. Zero work when probe disabled. + useEffect(() => { + if (renderStart > 0) { + perfProbe.recordRender(performance.now() - renderStart); + } + }); const stateRef = useRef(state); const messagesEndRef = useRef(null); const messagesScrollRef = useRef(null); @@ -2757,7 +2794,6 @@ function ChatContent() { const previousIsLoadingSessionRef = useRef(state.isLoadingSession); const previousReceivedInitStateRef = useRef(state.receivedInitState); const previousStreamingActiveRef = useRef(Boolean(state.streaming?.isActive)); - const didHydrateBootstrapRef = useRef(false); // Baseline renderMessages.length captured when isFollowing flips to false. // "Jump to latest (N)" renders N = currentLength - baseline. null = recapture // on next entry to the not-following branch. Replaces a throttled +1 timer @@ -2785,133 +2821,15 @@ function ChatContent() { useEffect(() => { stateRef.current = state; }, [state]); - // Hydrate last known session/centralized tape immediately on webview re-open so UI - // does not flash a blank/loading screen while extension bootstrap completes. - useEffect(() => { - if (didHydrateBootstrapRef.current) { - return; - } - didHydrateBootstrapRef.current = true; - - try { - const raw = window.sessionStorage.getItem(WEBVIEW_BOOTSTRAP_CACHE_KEY); - if (!raw) { - return; - } - const parsed = JSON.parse(raw) as { - currentSessionId?: string; - rawSdkEventPayloadsBySessionId?: Record; - }; - - const sessionId = - typeof parsed?.currentSessionId === "string" && - parsed.currentSessionId.trim().length > 0 - ? parsed.currentSessionId - : null; - const rawSdkEventPayloadsBySessionId = - parsed?.rawSdkEventPayloadsBySessionId && - typeof parsed.rawSdkEventPayloadsBySessionId === "object" - ? parsed.rawSdkEventPayloadsBySessionId - : {}; - - if (!sessionId) { - return; - } - - const cachedRawSdkEventPayloads = Array.isArray( - rawSdkEventPayloadsBySessionId[sessionId], - ) - ? rawSdkEventPayloadsBySessionId[sessionId] - : []; - - dispatch({ type: "SET_SESSION_ID", payload: sessionId }); - if (cachedRawSdkEventPayloads.length > 0) { - dispatch({ - type: "SET_RAW_SDK_EVENT_PAYLOADS", - payload: { sessionId, events: cachedRawSdkEventPayloads }, - }); - } - } catch { - // best-effort hydration only - } - }, [dispatch]); - - // Persist a lightweight session/centralized-tape snapshot for fast restore across - // sidebar/extension switches that recreate the webview. - useEffect(() => { - if (state.streaming?.isActive) { - return; - } - try { - const nextSnapshot = buildWebviewBootstrapSnapshot({ - currentSessionId: state.currentSessionId, - rawSdkEventPayloadsBySessionId: state.rawSdkEventPayloadsBySessionId, - }); - window.sessionStorage.setItem( - WEBVIEW_BOOTSTRAP_CACHE_KEY, - JSON.stringify(nextSnapshot), - ); - } catch { - // storage can fail in restricted webview scenarios; ignore gracefully - } - }, [state.currentSessionId, state.rawSdkEventPayloadsBySessionId, state.streaming?.isActive]); - useEffect(() => { streamViewportRef.current = streamViewport; }, [streamViewport]); - const centralizedSessionRawSdkEventPayloads = - state.currentSessionId && - Array.isArray(state.rawSdkEventPayloadsBySessionId?.[state.currentSessionId]) - ? state.rawSdkEventPayloadsBySessionId[state.currentSessionId] - : []; - - const [throttledPayloads, setThrottledPayloads] = useState(centralizedSessionRawSdkEventPayloads); - const projectionThrottleRef = useRef | null>(null); - - useEffect(() => { - if (centralizedSessionRawSdkEventPayloads === throttledPayloads) return; - // Rebuilding the centralized projection can include markdown, activity, - // block grouping, and height measurement. Keep the currently visible - // transcript stable while the user is reading older content. The final - // stream frame is still applied even while unfollowed so completed state - // is never left stale. - if (!streamViewport.isFollowing && state.streaming?.isActive) { - if (projectionThrottleRef.current !== null) { - clearTimeout(projectionThrottleRef.current); - projectionThrottleRef.current = null; - } - return; - } - if (projectionThrottleRef.current !== null) return; - projectionThrottleRef.current = setTimeout(() => { - projectionThrottleRef.current = null; - startTransition(() => { - setThrottledPayloads(centralizedSessionRawSdkEventPayloads); - }); - }, 100); - }, [ - centralizedSessionRawSdkEventPayloads, - throttledPayloads, - streamViewport.isFollowing, - state.streaming?.isActive, - ]); - - useEffect(() => { - return () => { - if (projectionThrottleRef.current !== null) { - clearTimeout(projectionThrottleRef.current); - } - }; - }, []); - - const transcriptProjection = useMemo( - () => buildCentralizedTranscriptProjection(throttledPayloads), - [throttledPayloads], - ); - const deferredTranscriptProjection = useDeferredValue(transcriptProjection); - const renderMessages = transcriptProjection.renderMessages; - const deferredRenderMessages = deferredTranscriptProjection.renderMessages; + // Completed history is the SDK `session.messages()` snapshot adapted by the + // extension host. Raw SDK events are retained only by the active streaming + // overlay and are never replayed or persisted as a second transcript. + const renderMessages = state.messages; + const deferredRenderMessages = useDeferredValue(renderMessages); // Priority 2 — Defer streaming-dependent props passed to MemoizedConversationTranscript. // During event streaming, these values change on every stream batch. Without deferral, // they defeat React.memo on the transcript and force full re-renders on the hot path, @@ -3031,9 +2949,20 @@ function ChatContent() { renderMessages.length, ]); - // Register message listener + // Register message listener. Wraps createMessageHandler with perf-probe + // timing so per-message-type handler cost can be attributed during streaming. useEffect(() => { - const handler = createMessageHandler(dispatch, () => stateRef.current); + const innerHandler = createMessageHandler(dispatch, () => stateRef.current); + const handler = (event: MessageEvent) => { + if (!perfProbe.isEnabled()) { + innerHandler(event); + return; + } + const msgType = (event.data as { type?: unknown })?.type; + const start = performance.now(); + innerHandler(event); + perfProbe.recordMessage(msgType, performance.now() - start); + }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [dispatch]); @@ -3215,8 +3144,23 @@ function ChatContent() { // Keep follow-mode pinned, but at a controlled cadence. This replaced a // MutationObserver-per-change strategy that was too eager during streaming. if (now - lastFollowAutoScrollAtRef.current >= 33) { - root.scrollTop = root.scrollHeight; lastFollowAutoScrollAtRef.current = now; + // Defer the scrollHeight read + scrollTop write to the next animation + // frame. Inside rAF the browser has already flushed pending DOM + // mutations and run layout, so reading scrollHeight does NOT force a + // synchronous layout reflow. The prior synchronous + // `root.scrollTop = root.scrollHeight` on every stream batch (this + // effect fires per state.streaming identity change, ~20-30+×/sec) + // forced the browser to flush pending DOM mutations on each batch and + // saturated the main thread, which was the dominant cause of the + // streaming lag/freezes users reported. Smooth scrolling resumes once + // streaming ends because this effect stops firing. + requestAnimationFrame(() => { + if (!streamViewportRef.current.isFollowing) return; + const el = messagesScrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }); } } if (streamViewportRef.current.unseenUpdateCount > 0) { @@ -3270,7 +3214,7 @@ function ChatContent() { state.processingSessionIds, ); const hasAnyRenderableConversation = - centralizedSessionRawSdkEventPayloads.length > 0 || + renderMessages.length > 0 || Boolean(state.streaming?.isActive); const hasLiveAssistantTurn = shouldDeferComposerSendInCurrentSession( state.currentSessionId, @@ -3339,8 +3283,24 @@ function ChatContent() { // Show AI response loading indicator (thinking bubble) when: // 1. NOT switching sessions (session loading takes precedence), AND // 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); + // FIXED: Use hasRenderableContent from SDK instead of checking content length. + // + // Loading must dismiss as soon as ANY visible streaming activity arrives — + // not just text content. Tool calls, reasoning, progress events, and + // interactive prompts all represent the assistant doing work that belongs + // in the streaming card, not behind a ThinkingBubble placeholder. Keeping + // the bubble up when these are already in state was causing users to see + // "stuck loading" even though tool calls had arrived. + const streamingForActivity = state.streaming; + const hasRenderableStreamingContent = Boolean( + streamingForActivity?.hasRenderableContent || + (streamingForActivity?.isActive && + ((streamingForActivity.reasoning?.length ?? 0) > 0 || + (streamingForActivity.reasoningEvents?.length ?? 0) > 0 || + (streamingForActivity.steps?.length ?? 0) > 0 || + (streamingForActivity.progressEvents?.length ?? 0) > 0 || + (streamingForActivity.interactiveEvents?.length ?? 0) > 0)), + ); let hasTerminalAssistantBlock = false; let terminalAssistantMessageId: string | null = null; @@ -3457,23 +3417,81 @@ function ChatContent() { const isCompressed = hasCompactedSegment && state.compactedMessagesCollapsed; const hiddenMessageCount = isCompressed ? compactionDividerIndex : 0; const visibleStartIndex = isCompressed ? compactionDividerIndex : 0; - const hasCentralizedSessionDiffEntries = useMemo( - () => - throttledPayloads.some((payload) => { - const event = asRecord(payload); - if (!event) return false; - const type = getCentralizedEventType(event); - if (type === "session.diff") return true; - if (type === "message.updated") { - const info = getCentralizedEventInfo(event); - const summary = asRecord(info?.summary); - return Array.isArray(summary?.diffs) && summary.diffs.length > 0; - } - return false; - }), - [throttledPayloads], + const rehydratedRenderBlocks = useMemo( + () => buildRenderBlocks({ kind: "rehydrated", messages: renderMessages }), + [renderMessages], + ); + const liveRenderBlocks = useMemo( + () => buildRenderBlocks({ + kind: "live", + messages: renderMessages, + streaming: presentedStreaming, + }), + [renderMessages, presentedStreaming], ); - const conversationEntries = transcriptProjection.conversationEntries; + const renderedLiveStreaming = liveRenderBlocks.find( + (block): block is Extract => block.kind === "streaming", + )?.streaming; + const conversationEntries = useMemo(() => { + const messageBlocks = rehydratedRenderBlocks.filter( + (block): block is Extract => block.kind === "message", + ); + const fileChangeBlocks = rehydratedRenderBlocks.filter( + (block): block is Extract => block.kind === "fileChanges", + ); + const fileChangeBlockByOwnerId = new Map( + fileChangeBlocks.map((block) => [block.ownerMessageId, block]), + ); + const emittedFileChangeOwnerIds = new Set(); + const entries: ConversationRenderEntry[] = []; + + for (let messageIndex = 0; messageIndex < messageBlocks.length; messageIndex += 1) { + const { message } = messageBlocks[messageIndex]; + const messageId = firstNonEmptyString(message.id, message.info?.id) ?? `message:${messageIndex}`; + const role = firstNonEmptyString(message.role, message.info?.role)?.toLowerCase(); + entries.push({ + kind: "message", + key: messageId, + message, + messageIndex, + order: entries.length, + // `session.messages()` can include server-authored system envelopes. + // Preserve that role here so the existing SystemMessage component is + // selected instead of treating every non-user message as an assistant + // response card. + renderKind: + role === "user" ? "user" : role === "system" ? "system" : "assistant", + }); + + // A session summary is carried by the SDK user envelope, but its visual + // placement belongs after that turn's final assistant envelope. Keep the + // data untouched and use the typed SDK parentID only to place its block. + const ownerUserMessageId = + role === "assistant" ? firstNonEmptyString(message.info?.parentID) : undefined; + if (!ownerUserMessageId || emittedFileChangeOwnerIds.has(ownerUserMessageId)) { + continue; + } + const nextMessage = messageBlocks[messageIndex + 1]?.message; + const nextRole = firstNonEmptyString(nextMessage?.role, nextMessage?.info?.role)?.toLowerCase(); + const nextParentId = firstNonEmptyString(nextMessage?.info?.parentID); + const isLastAssistantForUserTurn = + nextRole !== "assistant" || nextParentId !== ownerUserMessageId; + const fileChangeBlock = fileChangeBlockByOwnerId.get(ownerUserMessageId); + if (!isLastAssistantForUserTurn || !fileChangeBlock) { + continue; + } + emittedFileChangeOwnerIds.add(ownerUserMessageId); + entries.push({ + kind: "fileChanges", + key: `file-changes:${ownerUserMessageId}`, + message: fileChangeBlock.message, + ownerMessageId: ownerUserMessageId, + order: entries.length, + }); + } + + return entries; + }, [rehydratedRenderBlocks]); const baseVisibleConversationEntries = useMemo(() => { if (!isCompressed) { return conversationEntries; @@ -3584,6 +3602,27 @@ function ChatContent() { } return map; }, [visibleConversationEntries]); + // A rehydrated SDK summary belongs to its user envelope and is rendered as a + // dedicated transcript row. Suppress only the matching assistant block's + // generic file-change fallback, which may independently discover the same + // edit from a patch/tool part. + const hydratedFileChangesByBlockKey = useMemo(() => { + const blockKeys = new Set(); + let currentBlockKey = "initial"; + for (let index = 0; index < visibleConversationEntries.length; index += 1) { + const entry = visibleConversationEntries[index]; + if (entry.kind === "message") { + const role = firstNonEmptyString(entry.message.role, entry.message.info?.role); + if (role === "user") { + currentBlockKey = firstNonEmptyString(entry.message.info?.id, entry.message.id) ?? `user:${index}`; + } + } + if (entry.kind === "fileChanges") { + blockKeys.add(currentBlockKey); + } + } + return blockKeys; + }, [visibleConversationEntries]); const hasTranscriptAssistantForCurrentTurn = useMemo(() => { let lastUserEntryIndex = -1; for (let index = 0; index < visibleConversationEntries.length; index += 1) { @@ -3731,11 +3770,7 @@ function ChatContent() { {/* In-flow notification row: it reserves space instead of obscuring the transcript. */} ) : null} @@ -3854,13 +3888,14 @@ function ChatContent() { /> ) : null} - + 0 && visibleMessages[visibleMessages.length - 1].role === "assistant" diff --git a/webview/shared/src/chat/MessageComponents.tsx b/webview/shared/src/chat/MessageComponents.tsx index 0a07472..cc81048 100644 --- a/webview/shared/src/chat/MessageComponents.tsx +++ b/webview/shared/src/chat/MessageComponents.tsx @@ -70,6 +70,7 @@ import { useSubagentsForParentMessage } from "./hooks/useSubagents"; import { DiffStats } from "./DiffStats"; import { asString, + completedQuestionToolPresentation, getCentralizedAssistantContentChunksFromRawSdkEventPayloads, getCentralizedEventType, getCentralizedEventPart, @@ -93,8 +94,6 @@ import { FILE_MENTION_REGEX } from "./PanelComponents"; import type { ActivityDetail, AppState, - CentralizedDebugData, - CentralizedDebugSourceData, InteractiveEvent, Message, MessagePart, @@ -329,7 +328,10 @@ function isRenderableUserTextPart(part: MessagePart): boolean { : typeof part.content === "string" ? part.content : ""; - return !!text && !isSyntheticUserToolTextPart(splitInjectedSystemPromptFromUserText(text)); + return ( + !!text && + !isSyntheticUserToolTextPart(splitInjectedSystemPromptFromUserText(text).userText) + ); } function normalizeErrorLikeValue(value: unknown): string { @@ -770,7 +772,7 @@ function buildSearchPattern(...values: Array): string { return lines.join("\n"); } -// Component to extract bash output from message content +// Component to render command-like tool input and output in the shared terminal preview. function TerminalBlockWithOutput({ event, messageContent, @@ -778,10 +780,14 @@ function TerminalBlockWithOutput({ event: DisplayEvent; messageContent: string; }) { - // Use input.command as the authoritative command field - const command = ( - (event.activityDetail?.input as Record | undefined)?.["command"] as string - ) || event.activityDetail?.command || event.summary; + const input = event.activityDetail?.input as Record | undefined; + const globPattern = typeof input?.pattern === "string" ? input.pattern.trim() : ""; + const isGlob = event.label.trim().toLowerCase() === "glob"; + // Glob has the same input/output contract as Bash. Render the actual glob + // pattern as the terminal command so the activity is inspectable at a glance. + const command = isGlob && globPattern + ? `glob ${globPattern}` + : (input?.["command"] as string) || event.activityDetail?.command || event.summary; // Try to extract bash output from message content // Look for text after the command that looks like terminal output @@ -833,7 +839,7 @@ function TerminalBlockWithOutput({ return ( @@ -1446,11 +1452,7 @@ function extractSemanticEventMessageId(event: unknown): string | null { asRecord(asRecord(payloadRecord?.properties)?.info) || asRecord(payloadRecord?.info); - const part = - asRecord(asRecord(eventRecord.properties)?.part) || - asRecord(eventRecord.part) || - asRecord(asRecord(payloadRecord?.properties)?.part) || - asRecord(payloadRecord?.part); + const part = getCentralizedEventPart(eventRecord); const syncData = asRecord(asRecord(eventRecord.syncEvent)?.data) || @@ -2542,11 +2544,13 @@ function ResponseMessageBody({ parts, className, variant = "default", + isStreaming = false, }: { content?: string[]; parts?: MessagePart[]; className?: string; variant?: "default" | "bare"; + isStreaming?: boolean; }) { const chunkSource = Array.isArray(parts) && parts.length > 0 ? parts.map((part) => { @@ -2581,6 +2585,7 @@ function ResponseMessageBody({ ))} @@ -2789,6 +2794,10 @@ function thoughtItemsFromStreamingReasoningEvents( const createdAt = typeof event?.createdAt === "number" ? event.createdAt : index; + const startedAt = + typeof event?.startedAt === "number" ? event.startedAt : undefined; + const endedAt = + typeof event?.endedAt === "number" ? event.endedAt : undefined; const partID = asString(event?.partID).trim(); const messageID = asString(event?.messageID).trim(); const groupKey = partID || `${createdAt}:${index}`; @@ -2804,6 +2813,8 @@ function thoughtItemsFromStreamingReasoningEvents( status: isLiveChunk ? "pending" : "done", messageID: existing?.messageID ?? (messageID || undefined), partID: existing?.partID ?? (partID || undefined), + startedAt: existing?.startedAt ?? startedAt, + endedAt: existing?.endedAt ?? endedAt, }); }); @@ -2815,6 +2826,8 @@ function thoughtItemsFromStreamingReasoningEvents( status: item.status, messageID: item.messageID, partID: item.partID, + startedAt: item.startedAt, + endedAt: item.endedAt, })); } @@ -2847,8 +2860,9 @@ function mergeThoughtItemsForTimeline( const normalizedText = normalizeComparableText(item.text); if (item.partID) { indexByKey.set(`part:${item.partID}`, index); - } - if (item.messageID) { + } else if (item.messageID) { + // A message can contain many reasoning parts. Only fall back to its + // message ID when the SDK did not supply the part identity. indexByKey.set(`msg:${item.messageID}`, index); } if (normalizedText) { @@ -2862,7 +2876,7 @@ function mergeThoughtItemsForTimeline( const normalizedText = normalizeComparableText(item.text); const keys = [ item.partID ? `part:${item.partID}` : "", - item.messageID ? `msg:${item.messageID}` : "", + !item.partID && item.messageID ? `msg:${item.messageID}` : "", normalizedText ? `text:${normalizedText}` : "", ].filter(Boolean); @@ -2915,11 +2929,11 @@ function mergeProgressItemsForTimeline( const addKey = (item: ProgressItem, index: number) => { if (item.callID) { indexByKey.set(`call:${item.callID}`, index); - } - if (item.id) { + } else if (item.id) { indexByKey.set(`id:${item.id}`, index); - } - if (item.messageID) { + } else if (item.messageID) { + // Tool calls are siblings under one assistant message. A message ID is + // not a tool identity, so use it only when no call/part ID exists. indexByKey.set(`msg:${item.messageID}`, index); } indexByKey.set(`title:${normalizeComparableText(item.title)}`, index); @@ -2930,8 +2944,8 @@ function mergeProgressItemsForTimeline( for (const item of streamingItems) { const keys = [ item.callID ? `call:${item.callID}` : "", - item.id ? `id:${item.id}` : "", - item.messageID ? `msg:${item.messageID}` : "", + !item.callID && item.id ? `id:${item.id}` : "", + !item.callID && !item.id && item.messageID ? `msg:${item.messageID}` : "", `title:${normalizeComparableText(item.title)}`, ].filter(Boolean); @@ -3278,9 +3292,10 @@ function progressItemsFromRawEventPayloads( } const isQuestionTool = isQuestionToolName(tool); + const questionPresentation = completedQuestionToolPresentation(tool, status, output); const title = - (isQuestionTool && status === "done" - ? "Captured user response" + (questionPresentation.isCompleted + ? questionPresentation.title : undefined) || firstNonEmptyString( asString(part.title), @@ -3510,8 +3525,20 @@ function progressItemsFromSteps( steps .filter((step) => isActionProgressStep(step)) .forEach((step, index) => { - const title = step.title; + const rawActivityDetail = + "activityDetail" in step + ? (step.activityDetail as ActivityDetail | undefined) + : undefined; const status = normalizeProgressStatus(step.status); + const questionPresentation = completedQuestionToolPresentation( + rawActivityDetail?.tool, + step.status, + rawActivityDetail?.output, + ); + const title = questionPresentation.title ?? step.title; + const activityDetail = questionPresentation.isCompleted + ? { ...rawActivityDetail, summary: questionPresentation.summary ?? rawActivityDetail?.summary } + : rawActivityDetail; const meta = step.meta; const stepId = "id" in step && typeof step.id === "string" ? step.id : undefined; @@ -3552,21 +3579,19 @@ function progressItemsFromSteps( "partType" in step ? (step.partType as string | undefined) : undefined; const stepInternal = "internal" in step ? Boolean(step.internal) : false; + const baseMergeKey = + progressItemIdentityKey({ + id: stepId, + callID: stepCallId, + messageID: stepMessageId, + title, + filePath: stepFilePath, + partType: stepPartType, + activityDetail, + }) || mergeKey; const candidate: ProgressItem = { key: `${prefix}-${index}-${title}`, - mergeKey: - progressItemIdentityKey({ - id: stepId, - callID: stepCallId, - messageID: stepMessageId, - title, - filePath: stepFilePath, - partType: stepPartType, - activityDetail: - "activityDetail" in step - ? (step.activityDetail as ActivityDetail | undefined) - : undefined, - }) || mergeKey, + mergeKey: baseMergeKey, id: stepId, callID: stepCallId, messageID: stepMessageId, @@ -3586,7 +3611,7 @@ function progressItemsFromSteps( : undefined, activityDetail: "activityDetail" in step - ? (step.activityDetail as ActivityDetail | undefined) + ? activityDetail : undefined, streamSeq: "streamSeq" in step @@ -3597,16 +3622,16 @@ function progressItemsFromSteps( const existing = stepMap.get(candidate.mergeKey); if (!existing) { stepMap.set(candidate.mergeKey, candidate); - return; + } else { + stepMap.set(candidate.mergeKey, mergeProgressItemRecord(existing, candidate)); } - stepMap.set(candidate.mergeKey, mergeProgressItemRecord(existing, candidate)); }); return Array.from(stepMap.values()); } function progressItemsFromRawResponseParts( - rawResponse?: Message["rawResponse"], + rawResponse?: unknown, ): ProgressItem[] { if (!rawResponse) { return []; @@ -3686,7 +3711,11 @@ function progressItemsFromRawResponseParts( asString(partRec.output), ); const preview = firstNonEmptyString(asString(metadataRec?.preview)); - const rawTitle = toolName || "step"; + const rawTitle = firstNonEmptyString( + asString(stateRec?.title), + asString(partRec.title), + toolName, + ) || "step"; const compactMetadata: Record = {}; for (const [key, value] of Object.entries(metadataRec ?? {})) { @@ -4606,45 +4635,91 @@ function isDeltaCentralizedEventPayload(payload: unknown): boolean { ); } +type FileMentionTarget = { + filename: string; + path: string; +}; + +function hasInlineFileMention(text: string, filename: string): boolean { + if (!text || !filename) { + return false; + } + const token = `@${filename}`; + let index = text.indexOf(token); + while (index >= 0) { + const nextCharacter = text[index + token.length] || ""; + if (!/[a-zA-Z0-9_./\\-]/.test(nextCharacter)) { + return true; + } + index = text.indexOf(token, index + token.length); + } + return false; +} + // Function to parse text and extract file mentions -function parseFileMentions(text: string) { +function parseFileMentions(text: string, targets: FileMentionTarget[] = []) { if (!text) return []; - const parts: Array<{ type: 'text' | 'file'; content: string; filename?: string }> = []; + const parts: Array<{ + type: 'text' | 'file'; + content: string; + filename?: string; + path?: string; + }> = []; + const sortedTargets = [...targets] + .filter((target) => target.filename && target.path) + .sort((left, right) => right.filename.length - left.filename.length); let lastIndex = 0; - let match; - FILE_MENTION_REGEX.lastIndex = 0; // Reset regex state - while ((match = FILE_MENTION_REGEX.exec(text)) !== null) { - // Add text before the match - if (match.index > lastIndex) { + // SDK file parts are authoritative: only they can make a blue @ mention + // navigable. This avoids consuming adjacent synthetic transport text (for + // example, `@.env.exampleCalled the Read tool`) as part of a filename. + if (sortedTargets.length > 0) { + for (let index = 0; index < text.length; index += 1) { + const target = sortedTargets.find((candidate) => { + const token = `@${candidate.filename}`; + const nextCharacter = text[index + token.length] || ""; + return ( + text.startsWith(token, index) && + !/[a-zA-Z0-9_./\\-]/.test(nextCharacter) + ); + }); + if (!target) { + continue; + } + if (index > lastIndex) { + parts.push({ type: 'text', content: text.slice(lastIndex, index) }); + } parts.push({ - type: 'text', - content: text.slice(lastIndex, match.index) + type: 'file', + content: `@${target.filename}`, + filename: target.filename, + path: target.path, }); + lastIndex = index + target.filename.length + 1; + index = lastIndex - 1; + } + } else { + let match: RegExpExecArray | null; + FILE_MENTION_REGEX.lastIndex = 0; + while ((match = FILE_MENTION_REGEX.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ type: 'text', content: text.slice(lastIndex, match.index) }); + } + parts.push({ type: 'file', content: match[0], filename: match[1] }); + lastIndex = match.index + match[0].length; } - // Add the file mention - parts.push({ - type: 'file', - content: match[0], - filename: match[1] - }); - lastIndex = match.index + match[0].length; } - // Add remaining text if (lastIndex < text.length) { - parts.push({ - type: 'text', - content: text.slice(lastIndex) - }); + parts.push({ type: 'text', content: text.slice(lastIndex) }); } return parts.length > 0 ? parts : [{ type: 'text', content: text }]; } // Component to render text with highlighted file mentions -function renderHighlightedText(text: string) { - const parts = parseFileMentions(text); +function renderHighlightedText(text: string, targets?: FileMentionTarget[]) { + const parts = parseFileMentions(text, targets); return parts.map((part, index) => { const key = `${part.type}-${index}`; @@ -4657,10 +4732,10 @@ function renderHighlightedText(text: string) { // Open file when clicked vscode.postMessage({ type: "openFile", - file: (part as any).filename, + file: part.path || (part as any).filename, }); }} - title={`Open ${(part as any).filename}`} + title={`Open ${part.path || (part as any).filename}`} > {part.content} @@ -4813,7 +4888,17 @@ export function SharedActivityStep({ const isEditLike = ["edit", "modify", "patch", "write", "apply_patch"].includes(labelLower); const filePath = event.filePath || (event.activityDetail?.input as Record | undefined)?.filePath as string | undefined; const description = (event.activityDetail?.metadata?.description as string | undefined) || (event.activityDetail?.input?.description as string | undefined); - const visibleSummary = getVisibleDefaultActivitySummary(event.label, event.summary, event.filePath); + const globPattern = isGlobSearch && typeof event.activityDetail?.input?.pattern === "string" + ? event.activityDetail.input.pattern.trim() + : ""; + // Rehydrated raw SDK tool events can carry their only result in + // activityDetail.output. Use it as the last summary fallback so those events + // share the same collapsible preview as streamed activity rows. + const visibleSummary = getVisibleDefaultActivitySummary( + event.label, + event.summary, + event.filePath || event.activityDetail?.output, + ); return (
@@ -4821,6 +4906,7 @@ export function SharedActivityStep({
{event.label} + {globPattern ? {globPattern} : null} {description ? ·{description} : null} {(labelLower === "read" || isGlobSearch || isEditLike) && filePath && !isUrl(filePath) ? (
{!isReadActivity ? (
- {labelLower === "bash" ? ( + {labelLower === "bash" || isGlobSearch ? (
) : SEARCH_LABELS.has(event.label) ? ( @@ -6513,8 +6599,14 @@ export const UserMessage = memo(function UserMessage({ message, isQueued = false const [previewSelection, setPreviewSelection] = useState(null); const [copied, setCopied] = useState(false); const userMessageRef = useRef(null); + // Hydrated user messages can contain SDK synthetic Read-tool echoes beside + // the original text part. Prefer renderable user parts so server transport + // text and attached file contents never leak into the user bubble. const rawUserText = - message?.content ?? message?.text ?? messageBodyFromParts(message?.parts, message?.role ?? message?.info?.role); + messageBodyFromParts(message?.parts, message?.role ?? message?.info?.role) || + message?.content || + message?.text || + ""; const codeSelections = useMemo( () => collectCodeSelectionsFromParts(message?.parts), [message?.parts], @@ -6535,10 +6627,23 @@ export const UserMessage = memo(function UserMessage({ message, isQueued = false }, [message, rawUserText, codeSelections]); const content = splitContent.userText; const injectedSystemText = splitContent.systemText; - const explicitFileChips = (message?.parts ?? []) + const attachedFileChips = (message?.parts ?? []) .filter((part) => isExplicitFileAttachmentPart(part) && !isImageAttachmentPart(part)) .map((part) => buildExplicitFileChip(part)) .filter((value): value is { label: string; path?: string } => !!value); + const fileMentionTargets = Array.from( + new Map( + attachedFileChips + .filter((chip): chip is { label: string; path: string } => !!chip.path) + .map((chip) => [chip.label, { filename: chip.label, path: chip.path }]), + ).values(), + ); + // @ references are represented by their inline blue mention. Do not render a + // second attachment chip for the same SDK file part. Explicit uploads remain + // visible as chips when they are not referenced inline. + const explicitFileChips = attachedFileChips.filter( + (chip) => !hasInlineFileMention(content, chip.label), + ); const inferredFileChips = inferAttachmentPathsFromHydratedUserText( typeof rawUserText === "string" ? rawUserText : "", ).map((filePath) => ({ @@ -6627,11 +6732,11 @@ export const UserMessage = memo(function UserMessage({ message, isQueued = false return ( <> {match[1]} - {renderHighlightedText(match[2])} + {renderHighlightedText(match[2], fileMentionTargets)} ); } - return renderHighlightedText(content); + return renderHighlightedText(content, fileMentionTargets); })()}
{hasImages && ( @@ -6660,7 +6765,7 @@ export const UserMessage = memo(function UserMessage({ message, isQueued = false
{label.label} @@ -7199,9 +7304,13 @@ function formatThinkingVariantLabel(variant: string): string { return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase(); } -function getStableAgentAccentColor(agentName?: string): string | undefined { +function getStableAgentAccentColor(agentName?: string): string { if (!agentName || agentName === "assistant") { - return undefined; + // Default accent color so the agent name is always visually distinct in + // the header, even when the SDK hasn't surfaced a non-default agent + // name on the message envelope. Matches the empty-id hue from + // getSubagentHue for visual consistency with the subagent palette. + return `hsl(${getSubagentHue("")}, 72%, 68%)`; } return `hsl(${getSubagentHue(agentName)}, 72%, 68%)`; } @@ -7298,6 +7407,19 @@ function getAssistantTurnMetadataFromCentralizedEvents( if (asString(info.role) === "assistant") { const agent = asString(info.agent); if (agent) metadata.agent = agent; + // Mirror session.updated: check the nested info.model object first + // (SDK delivers model info as a nested object for parity with + // session.updated). Without this, metadata.providerID stays empty + // for message.updated-only turns and the header shows model-only. + const nestedModel = asRecord(info.model); + if (nestedModel) { + const nestedModelID = asString(nestedModel.modelID) || asString(nestedModel.id); + const nestedProviderID = asString(nestedModel.providerID); + const nestedVariant = asString(nestedModel.variant); + if (nestedModelID) metadata.modelID = nestedModelID; + if (nestedProviderID) metadata.providerID = nestedProviderID; + if (nestedVariant) metadata.variant = nestedVariant; + } const modelID = asString(info.modelID); const providerID = asString(info.providerID); const variant = asString(info.variant); @@ -7436,14 +7558,12 @@ function ResponseMessageInner({ assistantTurnPending, assistantTurnMessageId, streamingBySessionId, - rawSdkEventPayloadsBySessionId, selectedSubagentId, } = useAppState( (state) => ({ assistantTurnPending: state.assistantTurnPending, assistantTurnMessageId: state.assistantTurnMessageId, streamingBySessionId: state.streamingBySessionId, - rawSdkEventPayloadsBySessionId: state.rawSdkEventPayloadsBySessionId, selectedSubagentId: state.selectedSubagentId, }), shallowEqual, @@ -7503,18 +7623,13 @@ const centralizedRawResponse = message?.rawResponse; asString(centralizedMessageRec?.sessionID) || asString(centralizedMessageRec?.sessionId) || null; - const sessionScopedRawSdkEventPayloads = useMemo(() => { - if (!centralizedSessionId) { - return []; - } - - return Array.isArray(rawSdkEventPayloadsBySessionId?.[centralizedSessionId]) - ? rawSdkEventPayloadsBySessionId[centralizedSessionId] - : []; - }, [centralizedSessionId, rawSdkEventPayloadsBySessionId]); + // Live raw events are scoped to the active streaming turn. Completed cards + // use the SDK snapshot fields on `message` and never consult a session tape. + const sessionScopedRawSdkEventPayloads = + activityTimelineStreaming?.rawSdkEventPayloads ?? []; const hasCentralizedPendingAssistantReply = useMemo( - () => hasActiveAssistantReplyInCentralizedTape(sessionScopedRawSdkEventPayloads), - [sessionScopedRawSdkEventPayloads], + () => hasActiveAssistantReplyInCentralizedTape(activityTimelineStreaming?.rawSdkEventPayloads ?? []), + [activityTimelineStreaming?.rawSdkEventPayloads], ); const isLiveAssistantTurn = !!( activityTimelineStreaming?.isActive || @@ -7807,6 +7922,14 @@ const centralizedRawResponse = message?.rawResponse; [normalizedCentralizedRawSdkEventPayloads], ); const rawContent = rawContentChunks.join(""); + const snapshotContent = firstNonEmptyString( + cardMessage?.content, + cardMessage?.text, + cardMessage?.parts + ?.filter((part) => part?.type === "text") + .map((part) => firstNonEmptyString(part.text, part.content, part.message) ?? "") + .join(""), + ) ?? ""; const stickyStreamingContentRef = useRef<{ messageId: string | null; content: string; @@ -7825,7 +7948,7 @@ const centralizedRawResponse = message?.rawResponse; const content = scopedActivityTimelineStreaming?.isActive && rawContent.trim().length === 0 ? stickyStreamingContentRef.current.content - : rawContent; + : rawContent || snapshotContent; const hasAssistantFinishSignal = scopedActivityTimelineStreaming?.hasAssistantFinishSignal === true; const hasActiveReasoningPart = scopedActivityTimelineStreaming?.inReasoningPart === true; @@ -7858,6 +7981,12 @@ const centralizedRawResponse = message?.rawResponse; () => thoughtItemsFromRawEventPayloads(normalizedCentralizedRawSdkEventPayloads), [normalizedCentralizedRawSdkEventPayloads], ); + // Rehydrated messages come from `session.messages()` and retain reasoning as + // typed message parts. Use that snapshot when there is no live/raw overlay. + const snapshotThoughtItems = useMemo( + () => thoughtItemsFromStreamingReasoningEvents(cardMessage?.reasoningEvents, false), + [cardMessage?.reasoningEvents], + ); const liveThoughtItems = useMemo( () => thoughtItemsFromStreamingReasoningEvents( @@ -7867,14 +7996,36 @@ const centralizedRawResponse = message?.rawResponse; [scopedActivityTimelineStreaming?.reasoningEvents, scopedActivityTimelineStreaming?.isActive], ); const thoughtItems = useMemo( - () => mergeThoughtItemsForTimeline(finalizedThoughtItems, liveThoughtItems, isStreamingActive), - [finalizedThoughtItems, liveThoughtItems, isStreamingActive], + () => mergeThoughtItemsForTimeline( + mergeThoughtItemsForTimeline(finalizedThoughtItems, snapshotThoughtItems), + liveThoughtItems, + isStreamingActive, + ), + [finalizedThoughtItems, snapshotThoughtItems, liveThoughtItems, isStreamingActive], ); const progressItems = useMemo( () => { - return progressItemsFromCentralizedData(normalizedCentralizedRawSdkEventPayloads); + const fromRaw = progressItemsFromCentralizedData(normalizedCentralizedRawSdkEventPayloads); + if (fromRaw.length > 0) { + return fromRaw; + } + const fromSnapshotParts = progressItemsFromRawResponseParts({ + parts: cardMessage?.parts, + }); + if (fromSnapshotParts.length > 0) { + return fromSnapshotParts; + } + return progressItemsFromSteps( + [...(cardMessage?.progressEvents ?? []), ...(cardMessage?.steps ?? [])], + "sdk-snapshot", + ); }, - [normalizedCentralizedRawSdkEventPayloads], + [ + cardMessage?.parts, + cardMessage?.progressEvents, + cardMessage?.steps, + normalizedCentralizedRawSdkEventPayloads, + ], ); const liveProgressItems = useMemo( () => @@ -7903,8 +8054,9 @@ const centralizedRawResponse = message?.rawResponse; ); const structured = useMemo( - () => structuredOutputFromRawSdkEventPayloads(normalizedCentralizedRawSdkEventPayloads), - [normalizedCentralizedRawSdkEventPayloads] + () => structuredOutputFromRawSdkEventPayloads(normalizedCentralizedRawSdkEventPayloads) + ?? cardMessage?.structuredOutput, + [cardMessage?.structuredOutput, normalizedCentralizedRawSdkEventPayloads], ); const responseType = (structured?.type ?? structured?.responseType)?.toLowerCase(); const plan = structured?.plan; @@ -7949,27 +8101,6 @@ const centralizedRawResponse = message?.rawResponse; // 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( @@ -8927,14 +9058,18 @@ const centralizedRawResponse = message?.rawResponse; () => getLegacyMetricsDiagnostics(message, streaming), [message, streaming], ); + // Completed cards are SDK snapshots. The live raw overlay can enrich these + // values during an active turn, but it must never be required for them. + const snapshotTokens = message?.tokens ?? message?.info?.tokens; + const snapshotDuration = message?.duration ?? message?.info?.duration; const baseTokens = getTokenInfo( normalizedCentralizedRawSdkEventPayloads, assistantScopeMessageIds, - ); + ) ?? snapshotTokens; const baseDuration = getDuration( normalizedCentralizedRawSdkEventPayloads, assistantScopeMessageIds, - ); + ) ?? snapshotDuration; const tokens = isAssistantTurnCollapsed && blockTokens ? blockTokens : baseTokens; const duration = isAssistantTurnCollapsed && blockDuration !== undefined ? blockDuration : baseDuration; @@ -9104,15 +9239,20 @@ const responseBodyChunks = useMemo(() => { if (orderedChunks.length > 0) { return orderedChunks; } - return getCentralizedAssistantContentChunksFromRawSdkEventPayloads( + const rawChunks = getCentralizedAssistantContentChunksFromRawSdkEventPayloads( responseBodyRawSdkEventPayloads, ); + if (rawChunks.length > 0) { + return rawChunks; + } + return snapshotContent.trim().length > 0 ? [snapshotContent] : []; }, [ assistantScopeMessageIds, cardMessage, responseBodyRawSdkEventPayloads, streaming?.content, streaming?.hasRenderableContent, + snapshotContent, ]); const visibleResponseBodyChunks = useMemo(() => { const renderedQuestionOutputs = new Set( @@ -9531,7 +9671,11 @@ const retryLastMessage = (retryWithoutStructuredOutput: boolean) => { showThinkingPlaceholder || nonQuestionTimelineDisplayEventGroups.length > 0) && (
- {hasLiveSessionStatus ? ( + {/* Hide the session.status banner when statusType is "busy" — the AI response + loading indicator (ThinkingBubble / activity timeline) already conveys busy + state. Other status types (retry, error, idle, ready, etc.) must still + render so the user sees retry countdowns and surfacing session events. */} + {hasLiveSessionStatus && liveSessionStatus?.statusType !== "busy" ? (
{ {event.label} + {isGlobSearch && typeof event.activityDetail?.input?.pattern === "string" && event.activityDetail.input.pattern.trim() ? ( + + {event.activityDetail.input.pattern} + + ) : null} {compressTopic ? ( {compressTopic} @@ -9888,9 +10040,21 @@ const retryLastMessage = (retryWithoutStructuredOutput: boolean) => {
{/* For read, todowrite, and edit events, skip the generic summary block here — they have their own custom UI below. For all other events, render the file link or summary as usual. */} - {labelLower !== "read" && labelLower !== "todowrite" && !isEditLike && visibleSummary && ( + {isGlobSearch ? ( +
+ +
+ ) : labelLower !== "read" && labelLower !== "todowrite" && !isEditLike && visibleSummary && ( event.filePath && !isUrl(event.filePath) && event.label !== "bash" && !isCallStyleActivityLabel(event.label) ? ( - SEARCH_LABELS.has(event.label) ? ( + isGlobSearch ? ( + + ) : SEARCH_LABELS.has(event.label) ? ( { ) ) : (
- {event.label === "bash" ? ( + {event.label === "bash" || isGlobSearch ? ( { @@ -10510,6 +10675,8 @@ export const FileChangesSection = memo(function FileChangesSection({ }) { type DiffExcerpt = { header?: string; lines?: string[]; added?: number; deleted?: number }; type FileChange = { file: string; added: number; deleted: number; diffExcerpt?: DiffExcerpt }; + // The SDK envelope that owns the summary/diff is the revert target. In + // particular, rehydrated `info.summary.diffs` belong to the user message. const summaryMessageId = firstNonEmptyString(centralizedDiffEvent?.messageId, changeSummary?.messageId, messageId) || null; const compactDisplayDir = (dir: string): string => { @@ -11101,24 +11268,17 @@ export const EmptyState = memo(function EmptyState({ serverError, receivedInitState, currentSessionId, - rawSdkEventPayloadsBySessionId, }: { serverStatus: AppState["serverStatus"]; serverError?: string; receivedInitState: AppState["receivedInitState"]; currentSessionId: AppState["currentSessionId"]; - rawSdkEventPayloadsBySessionId: AppState["rawSdkEventPayloadsBySessionId"]; }) { const iconUri = typeof document !== "undefined" ? document.getElementById("root")?.dataset.opencodeIconUri : undefined; - const hasCachedCurrentSessionMessages = Boolean( - currentSessionId && - (rawSdkEventPayloadsBySessionId?.[currentSessionId]?.length ?? 0) > 0, - ); - const isConnecting = false; if (isConnecting) { @@ -11214,67 +11374,50 @@ export function MessageStatus({ ); } -export const CentralizedDebugPanel = memo(function CentralizedDebugPanel() { - if (!config.debug.showCentralizedDebug) { +export const SdkEventDebugPanel = memo(function SdkEventDebugPanel() { + if (!config.debug.showSdkEventDebug) { return null; } - return ; + return ; }); -const CentralizedDebugPanelContents = memo(function CentralizedDebugPanelContents() { - const [copiedDebugPanel, setCopiedDebugPanel] = useState<"centralized" | null>(null); +const SdkEventDebugPanelContents = memo(function SdkEventDebugPanelContents() { + const [copiedDebugPanel, setCopiedDebugPanel] = useState(false); const { currentSessionId, - errorMessages, - rawSdkEventPayloadsBySessionId, + sdkMessagesBySessionId, liveEventStreamBySessionId, - subagentsByParentMessageId, - subagentDetailsById, - receivedInitState, - serverStatus, } = useAppState( (state) => ({ currentSessionId: state.currentSessionId, - errorMessages: state.errorMessages, - rawSdkEventPayloadsBySessionId: state.rawSdkEventPayloadsBySessionId, + sdkMessagesBySessionId: state.sdkMessagesBySessionId, liveEventStreamBySessionId: state.liveEventStreamBySessionId, - subagentsByParentMessageId: state.subagentsByParentMessageId, - subagentDetailsById: state.subagentDetailsById, - receivedInitState: state.receivedInitState, - serverStatus: state.serverStatus, }), shallowEqual, ); - const centralizedSessionId = currentSessionId; - const rawSdkEventPayloads = centralizedSessionId && Array.isArray(rawSdkEventPayloadsBySessionId?.[centralizedSessionId]) - ? rawSdkEventPayloadsBySessionId[centralizedSessionId] + const sessionId = currentSessionId; + const rehydratedSdkMessages = sessionId && Array.isArray(sdkMessagesBySessionId?.[sessionId]) + ? sdkMessagesBySessionId[sessionId] : []; - const liveEventStream = centralizedSessionId && Array.isArray(liveEventStreamBySessionId?.[centralizedSessionId]) - ? liveEventStreamBySessionId[centralizedSessionId] + const liveEvents = sessionId && Array.isArray(liveEventStreamBySessionId?.[sessionId]) + ? liveEventStreamBySessionId[sessionId] : []; - const centralizedData = { - schemaVersion: 1, - sessionId: centralizedSessionId, - rawEventStream: { events: rawSdkEventPayloads }, - // Debug-only: populated only while this webview is alive and intentionally - // absent after chatHistory rehydrates a session. - liveEventStream: { persistence: "client-only", events: liveEventStream }, - subagents: { - summariesByParentMessageId: subagentsByParentMessageId, - detailsById: subagentDetailsById, - }, + const debugData = { + sessionId, + rehydratedSdkMessages, + liveEvents, }; return (
- Centralized Data (Debug) + SDK Events (Debug)
-
{JSON.stringify(centralizedData, null, 2)}
+
{JSON.stringify(debugData, null, 2)}
); diff --git a/webview/shared/src/chat/PanelComponents.tsx b/webview/shared/src/chat/PanelComponents.tsx index 0d05bff..809eabe 100644 --- a/webview/shared/src/chat/PanelComponents.tsx +++ b/webview/shared/src/chat/PanelComponents.tsx @@ -180,6 +180,38 @@ function CircularProgress({ // Regex to match @filename references (shared between components) export const FILE_MENTION_REGEX = /@([a-zA-Z0-9_\-./\\]+[a-zA-Z0-9])/g; +/** + * Resolves file mentions selected from the @ autocomplete into the same + * context payload used by explicit file attachments. The message retains its + * @path text for the model, while the extension host turns these contexts + * into SDK `file` parts containing the referenced file's content. + */ +export function resolveMentionedFileContexts( + text: string, + selectedContexts: ContextItem[], + fileMentionPaths: Record, +): ContextItem[] | undefined { + const contexts = [...selectedContexts]; + const attachedPaths = new Set(contexts.map((context) => context.file)); + + for (const match of text.matchAll(FILE_MENTION_REGEX)) { + const mention = match[1]; + const filePath = fileMentionPaths[mention]; + if (!filePath || attachedPaths.has(filePath)) { + continue; + } + + contexts.push({ + file: filePath, + lineInfo: "", + content: "", + }); + attachedPaths.add(filePath); + } + + return contexts.length > 0 ? contexts : undefined; +} + type SlashTrigger = { query: string; replaceFrom: number; @@ -2452,7 +2484,11 @@ export const InputWrapper = memo(function InputWrapper() { // Capture values before clearing state const currentFiles = selectedFiles.length > 0 ? [...selectedFiles] : undefined; - const currentContexts = selectedContexts.length > 0 ? [...selectedContexts] : undefined; + const currentContexts = resolveMentionedFileContexts( + text, + selectedContexts, + fileMentionPaths, + ); const currentAttachments = attachments || []; const currentAgent = selectedAgent || null; const sessionId = currentSessionId; @@ -2478,29 +2514,26 @@ export const InputWrapper = memo(function InputWrapper() { e.type === "confirm" || e.type === "quick_input", ); - // Only paint an optimistic user bubble when the composer is NOT in a live - // assistant turn. When sending during an active response the server queues - // the message natively and echoes it back via the SSE stream; the real - // message then shows a "QUEUED" badge derived from the message tape - // (mirrors the opencode TUI — no client-side queue state required). + // Every normal send belongs in the inline transcript immediately. OpenCode + // owns ordering while a turn is active; the webview never shows a separate + // extension-side pending queue for these messages. const pendingSessionId = sessionId ?? PENDING_CURRENT_SESSION_KEY; - if (!hasLiveAssistantTurn) { - dispatch({ - type: "ADD_PENDING_USER_MESSAGE", - payload: { - id: clientRequestId, - clientRequestId, - sessionId: pendingSessionId, - createdAt: Date.now(), - text, - images: currentAttachments - .filter((attachment) => isImageAttachment(attachment.mimeType, attachment.dataUrl)) - .map((attachment) => attachment.dataUrl), - attachments: currentAttachments, - interactiveSubmit: hasPendingQuestion, - }, - }); - } + dispatch({ + type: "ADD_PENDING_USER_MESSAGE", + payload: { + id: clientRequestId, + clientRequestId, + sessionId: pendingSessionId, + createdAt: Date.now(), + text, + images: currentAttachments + .filter((attachment) => isImageAttachment(attachment.mimeType, attachment.dataUrl)) + .map((attachment) => attachment.dataUrl), + attachments: currentAttachments, + contexts: currentContexts, + interactiveSubmit: hasPendingQuestion, + }, + }); vscode.postMessage({ type: "sendMessage", @@ -2535,7 +2568,11 @@ export const InputWrapper = memo(function InputWrapper() { ...(currentSessionId ? { sessionId: currentSessionId } : {}), text, files: selectedFiles, - contexts: selectedContexts, + contexts: resolveMentionedFileContexts( + text, + selectedContexts, + fileMentionPaths, + ), agent: selectedAgent || null, images: attachments || [], }); @@ -2840,7 +2877,6 @@ export const InputWrapper = memo(function InputWrapper() {
- {!showInteractivePopover && } {event && (
diff --git a/webview/shared/src/chat/SubagentDetailModal.tsx b/webview/shared/src/chat/SubagentDetailModal.tsx index d966994..a323405 100644 --- a/webview/shared/src/chat/SubagentDetailModal.tsx +++ b/webview/shared/src/chat/SubagentDetailModal.tsx @@ -23,6 +23,19 @@ function record(value: unknown): Record | undefined { : undefined; } +/** Convert persisted SDK tool data into text without losing object-shaped output. */ +function toolDataText(value: unknown): string { + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (value === undefined || value === null) return ""; + + try { + return JSON.stringify(value, null, 2); + } catch { + return ""; + } +} + function rawToolTimeline(detail: SubagentDetail, forceCancelled: boolean): ModalTimelineEvent[] { const events = Array.isArray(detail.rawEvents) ? detail.rawEvents : []; const byPartId = new Map(); @@ -49,6 +62,12 @@ function rawToolTimeline(detail: SubagentDetail, forceCancelled: boolean): Modal : typeof startedAt === "number" ? startedAt : Date.now(); const key = partId || `${tool}:${createdAt}`; const input = record(state?.input); + const output = toolDataText(state?.output); + const inputSummary = + toolDataText(input?.command) || + toolDataText(input?.pattern) || + toolDataText(input?.url) || + toolDataText(input?.query); const existing = byPartId.get(key); byPartId.set(key, { id: key, @@ -58,10 +77,14 @@ function rawToolTimeline(detail: SubagentDetail, forceCancelled: boolean): Modal key, kind: "activity", label: tool, - summary: typeof input?.command === "string" ? input.command : typeof input?.pattern === "string" ? input.pattern : "", + // The raw rehydration tape is the only source for completed tool + // results (e.g. webfetch). Keep its output on the normal activity + // detail contract so SharedActivityStep renders it in the existing + // collapsible content preview. + summary: output || inputSummary, status, startedAt: typeof startedAt === "number" ? startedAt : createdAt, - activityDetail: { tool, input: input ?? {} }, + activityDetail: { tool, input: input ?? {}, output }, updateCount: 1, }, }); diff --git a/webview/shared/src/chat/components/SessionModal.tsx b/webview/shared/src/chat/components/SessionModal.tsx index 9f6d622..4780774 100644 --- a/webview/shared/src/chat/components/SessionModal.tsx +++ b/webview/shared/src/chat/components/SessionModal.tsx @@ -16,12 +16,10 @@ export function SessionModal({ isOpen, onClose }: SessionModalProps) { sessionsList, currentSessionId, processingSessionIds, - rawSdkEventPayloadsBySessionId, } = useAppState((s) => ({ sessionsList: s.sessionsList, currentSessionId: s.currentSessionId, processingSessionIds: s.processingSessionIds, - rawSdkEventPayloadsBySessionId: s.rawSdkEventPayloadsBySessionId, }), shallowEqual); const dispatch = useAppDispatch(); const [editingSessionId, setEditingSessionId] = useState(null); @@ -194,23 +192,15 @@ export function SessionModal({ isOpen, onClose }: SessionModalProps) { // Find the session to get its title const session = sessionsList.find(s => s.id === sessionId); const sessionTitle = session?.title || sessionId; - const hasCachedCentralizedData = - (rawSdkEventPayloadsBySessionId?.[sessionId]?.length ?? 0) > 0; // Switch local session context immediately so session-scoped UI (like // pending queue items above composer) does not bleed across sessions // while we wait for backend hydration. dispatch({ type: "SET_SESSION_ID", payload: sessionId }); - // Skip full loading state when we already have a local render cache. - if (!hasCachedCentralizedData) { - dispatch({ - type: "START_SESSION_LOADING", - payload: { - sessionId, - title: sessionTitle - } - }); - } + dispatch({ + type: "START_SESSION_LOADING", + payload: { sessionId, title: sessionTitle }, + }); vscode.postMessage({ type: "switchSession", sessionId }); onClose(); diff --git a/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts b/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts index e915ce5..323b77b 100644 --- a/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts +++ b/webview/shared/src/chat/lib/assistantBlockPresentation.test.ts @@ -44,4 +44,22 @@ describe("buildAssistantBlockPresentation", () => { assert.equal(result.isLastTextInBlockByIndex.get(1), false); assert.equal(result.blockSizeByKey.get("user-1"), 1); }); + + it("keeps separate SDK assistant message IDs as separate response blocks", () => { + const result = buildAssistantBlockPresentation([ + { role: "user", userBlockKey: "user-1" }, + { role: "assistant", assistantBlockKey: "assistant-tool-call", hasResponseText: false }, + { role: "assistant", assistantBlockKey: "assistant-final", hasResponseText: true }, + ]); + + assert.deepStrictEqual(result.entryBlockKeys, [ + "user-1", + "assistant-tool-call", + "assistant-final", + ]); + assert.equal(result.blockSizeByKey.get("assistant-tool-call"), 1); + assert.equal(result.blockSizeByKey.get("assistant-final"), 1); + assert.equal(result.isLastTextInBlockByIndex.get(1), false); + assert.equal(result.isLastTextInBlockByIndex.get(2), false); + }); }); diff --git a/webview/shared/src/chat/lib/assistantBlockPresentation.ts b/webview/shared/src/chat/lib/assistantBlockPresentation.ts index e2b485a..7e7b53a 100644 --- a/webview/shared/src/chat/lib/assistantBlockPresentation.ts +++ b/webview/shared/src/chat/lib/assistantBlockPresentation.ts @@ -8,6 +8,13 @@ export interface AssistantBlockPresentationEntry { role?: string; /** Stable user message id, supplied only for user entries. */ userBlockKey?: string; + /** + * Stable assistant message id. When available, an SDK message envelope is + * its own response block even when it shares a parent user message with + * another assistant envelope (for example, a tool-call turn then a final + * answer). + */ + assistantBlockKey?: string; /** True when the assistant entry has user-visible response text. */ hasResponseText?: boolean; /** True when an aborted assistant entry must be represented inline. */ @@ -45,7 +52,11 @@ export function buildAssistantBlockPresentation( if (role === "user") { currentBlockKey = entry.userBlockKey || `user:${index}`; } else if (role === "assistant") { - assistantEntries.push({ index, key: currentBlockKey }); + const assistantBlockKey = entry.assistantBlockKey?.trim(); + const key = assistantBlockKey || currentBlockKey; + assistantEntries.push({ index, key }); + entryBlockKeys.push(key); + return; } entryBlockKeys.push(currentBlockKey); }); diff --git a/webview/shared/src/chat/lib/messageHandler.test.ts b/webview/shared/src/chat/lib/messageHandler.test.ts index 6e763c1..9c8316c 100644 --- a/webview/shared/src/chat/lib/messageHandler.test.ts +++ b/webview/shared/src/chat/lib/messageHandler.test.ts @@ -12,6 +12,7 @@ import { getCentralizedAssistantTurnCompletionIndex, isAiResponseEvent, structuredOutputFromRawSdkEventPayloads, + terminalEventMatchesAssistantTurn, } from './messageHandler'; import { getFinalAssistantResponseText, @@ -54,6 +55,106 @@ describe('normalizeMessage - responseType handling', () => { }); }); +describe('terminalEventMatchesAssistantTurn', () => { + it('matches a terminal step event by its direct messageID', () => { + assert.equal( + terminalEventMatchesAssistantTurn( + { + type: 'message.part.updated', + properties: { + part: { type: 'step-finish', messageID: 'msg-parent' }, + }, + }, + ['msg-parent'], + ), + true, + ); + }); + + it('matches a deeply nested background-agent terminal event by parentMessageId', () => { + assert.equal( + terminalEventMatchesAssistantTurn( + { + payload: { + syncEvent: { + data: { + part: { + type: 'step-finish', + messageID: 'msg-background-agent', + parentMessageId: 'msg-parent', + }, + }, + }, + }, + }, + ['msg-parent'], + ), + true, + ); + }); + + it('does not let another assistant turn clear the active loading state', () => { + assert.equal( + terminalEventMatchesAssistantTurn( + { + properties: { + part: { type: 'step-finish', messageID: 'msg-other' }, + }, + }, + ['msg-parent'], + ), + false, + ); + }); +}); + +describe('createMessageHandler - terminal turn identity', () => { + it('ends loading when a matching step-finish arrives for the active assistant turn', () => { + let state = { + ...initialState, + currentSessionId: 'ses-terminal', + isProcessing: true, + assistantTurnPending: true, + assistantTurnMessageId: 'msg-parent', + streaming: { + messageId: 'msg-parent', + content: 'Completed response', + reasoning: '', + reasoningEvents: [], + steps: [], + progressEvents: [], + edits: [], + isActive: true, + }, + } as AppState; + const dispatch = (action: Parameters[1]) => { + state = appReducer(state, action); + }; + const handler = createMessageHandler(dispatch, () => state); + + handler({ + data: { + type: 'streamEvent', + sessionId: 'ses-terminal', + event: { + type: 'message.part.updated', + properties: { + part: { + type: 'step-finish', + messageID: 'msg-parent', + reason: 'stop', + }, + }, + }, + }, + } as MessageEvent); + + assert.equal(state.isProcessing, false); + assert.equal(state.assistantTurnPending, false); + assert.equal(state.streaming?.isActive, false); + }); +}); + describe('createMessageHandler - chatHistory hydration guards', () => { it('should tolerate missing availableModels while recalculating context usage from chatHistory', () => { let state = { diff --git a/webview/shared/src/chat/lib/messageHandler.ts b/webview/shared/src/chat/lib/messageHandler.ts index 8dac6e3..6b6fc49 100644 --- a/webview/shared/src/chat/lib/messageHandler.ts +++ b/webview/shared/src/chat/lib/messageHandler.ts @@ -59,7 +59,6 @@ import { routeLiveEvent } from "./liveEventRouter"; import { extractSubagentsFromCentralizedEvents as modularExtractSubagentsFromCentralizedEvents } from './subagents/centralExtractor'; -import { resolveHydratedSubagentProjection } from "./subagents/hydrationSource"; import { mergeSubagentSummaries, hasSubagentSummaryEntries, @@ -194,6 +193,74 @@ export function extractEventMessageId(event: unknown): string | null { : null; } +/** + * Returns every message identity carried by a stream envelope. Background + * agents can wrap their terminal event in several `payload`/`data` layers; + * their child message id is not the turn that owns the chat loading state, but + * their `parentMessageId` is. Keep this structural rather than text-based. + */ +function terminalEventMessageIds(event: unknown): Set { + const messageIds = new Set(); + const visited = new Set(); + const identityKeys = new Set([ + "messageid", + "message_id", + "parentmessageid", + "parent_message_id", + "parentid", + "parent_id", + ]); + + const visit = (value: unknown, allowId = false): void => { + if (!value || typeof value !== "object") { + return; + } + if (visited.has(value)) { + return; + } + visited.add(value); + + if (Array.isArray(value)) { + value.forEach((item) => visit(item)); + return; + } + + for (const [key, child] of Object.entries(value as Record)) { + const normalizedKey = key.replace(/[-_]/g, "").toLowerCase(); + if ( + (identityKeys.has(normalizedKey) || (allowId && normalizedKey === "id")) && + typeof child === "string" && + child.trim() + ) { + messageIds.add(child.trim()); + } + // `id` is a message id only within a semantic message/info object. + visit(child, normalizedKey === "info" || normalizedKey === "message"); + } + }; + + visit(event); + return messageIds; +} + +export function terminalEventMatchesAssistantTurn( + event: unknown, + activeMessageIds: Array, +): boolean { + const activeIds = new Set( + activeMessageIds.filter( + (messageId): messageId is string => + typeof messageId === "string" && messageId.trim().length > 0, + ), + ); + if (activeIds.size === 0) { + return false; + } + + const eventIds = terminalEventMessageIds(event); + return [...activeIds].some((messageId) => eventIds.has(messageId)); +} + function summarizeStreamEventForLog(payload: UnknownRecord): Record { const properties = asRecord(payload.properties); const partRecord = asRecord(payload.part) ?? asRecord(properties?.part); @@ -399,6 +466,23 @@ export function asString(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback; } +/** One presentation contract for completed SDK question tools, shared by live and hydrated UI. */ +export function completedQuestionToolPresentation( + tool: unknown, + status: unknown, + output?: unknown, +): { isCompleted: boolean; title?: string; summary?: string } { + const normalizedTool = asString(tool).trim().toLowerCase(); + const normalizedStatus = asString(status).trim().toLowerCase(); + const summary = asString(output).trim(); + const isCompleted = + normalizedTool === "question" && + ["completed", "complete", "done", "success", "finished"].includes(normalizedStatus); + return isCompleted + ? { isCompleted: true, title: "Captured user response", summary: summary || undefined } + : { isCompleted: false }; +} + export function getCentralizedEventPart(payload: unknown): UnknownRecord | null { const event = asRecord(payload); if (!event) { @@ -438,6 +522,25 @@ export function getCentralizedEventPart(payload: unknown): UnknownRecord | null return propertiesPart; } + // Session hydration can supply an SDK message part directly instead of the + // usual event envelope. In particular, a completed `question` tool part is + // emitted after the user replies and carries the reply text in + // `state.output`. Treat that record as the part itself so its message + // ownership, terminal status, and output survive centralized rendering. + const directType = asString(event.type).trim().toLowerCase(); + if ( + directType === "tool" && + !!asRecord(event.state) && + !!firstNonEmptyString( + asString(event.messageID), + asString(event.messageId), + asString(event.callID), + asString(event.callId), + ) + ) { + return event; + } + return null; } @@ -1506,8 +1609,15 @@ export function resolveStreamingContentUpdate( return { content: incomingChunk, append: false }; } - const currentNormalized = currentContent.replace(/\r\n/g, '\n'); - const incomingNormalized = incomingChunk.replace(/\r\n/g, '\n'); + // Perf: short-circuit the \r\n -> \n regex when no \r is present (the common + // case after the first normalization). On 150KB streaming content × 160 + // calls/sec this avoids ~24M regex char-scans/sec. + const currentNormalized = currentContent.includes('\r') + ? currentContent.replace(/\r\n/g, '\n') + : currentContent; + const incomingNormalized = incomingChunk.includes('\r') + ? incomingChunk.replace(/\r\n/g, '\n') + : incomingChunk; if (incomingNormalized === currentNormalized) { return null; @@ -1731,6 +1841,37 @@ function normalizeActivityDiffExcerpt( }; } +function diffExcerptFromPatch( + patch: unknown, + diffStats?: { added?: number; deleted?: number }, +): ActivityDetail["diffExcerpt"] { + const text = asOptionalString(patch); + if (!text) { + return undefined; + } + const lines = text + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.replace(/\s+$/u, "")) + .filter((line) => + line.length > 0 && + !line.startsWith("Index: ") && + !line.startsWith("===================================================================") && + !line.startsWith("--- ") && + !line.startsWith("+++ "), + ) + .slice(0, 40); + if (lines.length === 0) { + return undefined; + } + return { + header: lines.find((line) => line.startsWith("@@")), + lines, + added: diffStats?.added ?? lines.filter((line) => line.startsWith("+")).length, + deleted: diffStats?.deleted ?? lines.filter((line) => line.startsWith("-")).length, + }; +} + const FILE_PATH_HINT_KEYS = new Set([ "file", "filepath", @@ -4506,6 +4647,7 @@ function upsertStreamingStep( payload: { index: idx, patch: { + messageID: step.messageID || current.messageID, title, type: step.type || current.type, status: nextStatus || current.status, @@ -6413,14 +6555,9 @@ function handleSubagentUpdatesFromCentralizedEvents( ): void { const currentState = getState(); - // NOTE: AppState does not have a top-level rawSdkEventPayloads field. - // The centralized tape is stored in rawSdkEventPayloadsBySessionId keyed by - // session ID. Reading the wrong field would silently return undefined and - // starve the extractor, producing no subagent data after every messageResponse. - const sessionEvents = - currentState.currentSessionId - ? (currentState.rawSdkEventPayloadsBySessionId?.[currentState.currentSessionId] ?? []) - : []; + // Only the active stream has raw events. Completed turns are reconstructed + // from `session.messages()` and carry their subtask parts on Message. + const sessionEvents = currentState.streaming?.rawSdkEventPayloads ?? []; // Use the modular extraction system const { summariesByParentMessageId, detailsById } = @@ -6699,7 +6836,10 @@ function extractMessageText(message: Message): string { return summary; } - return reasoningFromParts(parts); + // Reasoning is timeline-only. Returning it as a generic text fallback lets + // a reasoning-only SDK envelope be mistaken for assistant response content + // by hydration/deduplication paths, leaking thought text into the bubble. + return ""; } function isInternalSystemReminderMessage(message: Message): boolean { @@ -6722,12 +6862,18 @@ function isInternalSystemReminderMessage(message: Message): boolean { // Check for square-bracketed system messages at the start (e.g., [analyze-mode], [background task completed]) const bracketPattern = /^\[[a-z][a-z0-9_\- ]*\]/i; const hasBracketPrefix = bracketPattern.test(text); + // OpenCode can carry server-authored directives as user-role transport + // envelopes. Treat a leading XML-like tag (for example ``) the + // same way as the existing bracketed directive forms. + const angleTagPattern = /^<[a-z][a-z0-9_\-]*>/i; + const hasAngleTagPrefix = angleTagPattern.test(text); return ( normalizedText.includes("") || normalizedText.includes("") || normalizedText.includes("") || hasBracketPrefix || + hasAngleTagPrefix || (normalizedText.includes("[search-model]") && normalizedText.includes("maximize search effort")) ); @@ -8391,6 +8537,43 @@ function finalizeStreamingSnapshotSteps( }; } +function completeStreamingTurnFromTerminalEvent( + dispatch: Dispatch, + getState: () => AppState, + payload: UnknownRecord, + terminalStatus: "done" | "error" = "done", +): boolean { + const state = getState(); + const streaming = state.streaming; + if ( + !streaming?.isActive || + !terminalEventMatchesAssistantTurn(payload, [ + streaming.messageId, + state.assistantTurnMessageId, + ]) + ) { + return false; + } + + const finalized = finalizeStreamingSnapshotSteps(streaming, terminalStatus); + if (finalized) { + dispatch({ + type: "SET_STREAMING", + payload: { + ...finalized, + hasTerminalStepSignal: true, + }, + }); + } + dispatch({ + type: "SET_ASSISTANT_TURN_PENDING", + payload: { pending: false, messageId: null }, + }); + dispatch({ type: "SET_PROCESSING", payload: false }); + dispatch({ type: "FINISH_STREAMING" }); + return true; +} + function handleStreamEvent( dispatch: Dispatch, getState: () => AppState, @@ -8722,6 +8905,20 @@ function handleStreamEvent( // response than the one currently streaming (e.g. from a background task or a stale // previous turn), we must drop the stream-mutating portion of the event to prevent // it from bleeding into the active UI streaming card. + // + // A completed question tool is the intentional exception. OpenCode completes the + // question on the assistant message that asked it *after* the user-reply flow has + // opened its continuation placeholder. Dropping that event loses `state.output` + // (the captured answer) before the activity timeline can render it. + const strayEventIsCompletedQuestionTool = (() => { + const part = eventPart ?? partRecord; + const toolState = asRecord(part?.state); + return completedQuestionToolPresentation( + part?.tool, + toolState?.status ?? part?.status, + toolState?.output, + ).isCompleted; + })(); const isStrayEvent = (() => { if (!messageId) return false; const currentStreamingMessageId = getState().streaming?.messageId; @@ -8734,7 +8931,7 @@ function handleStreamEvent( if (normalizedEventType === "start" || normalizedEventType === "streamStart") return false; if (normalizedEventType === "message.updated") return false; - return true; + return !strayEventIsCompletedQuestionTool; })(); if (isStrayEvent) { @@ -8822,10 +9019,29 @@ function handleStreamEvent( // reasoning detection misses them entirely — causing delta chunks to be mistakenly // routed to the content pipeline instead of the reasoning timeline. const deltaField = asString(properties?.field).trim().toLowerCase(); + const rawUpdatedDelta = + asRichString(properties?.delta) || + asRichString(payload.delta) || + asRichString(part.delta); + const rawPartText = asRichString(part.text) || asRichString(part.content); const isRawDeltaReasoningField = eventType === "message.part.delta" && (deltaField === "reasoning" || deltaField === "thinking"); - const isReasoning = currentPartType === 'reasoning' || currentStructuredKind === 'thinking' || isRawDeltaReasoningField; + // Some providers emit an intermediate thought as a `message.part.updated` + // text snapshot instead of a typed reasoning part. Its full `part.text` + // exactly equals the delta. Normal response token streaming uses the + // `message.part.delta` event, so this structural distinction keeps the + // thought out of the response card without inspecting its wording. + const isDeltaOnlyUpdatedTextSnapshot = + eventType === "message.part.updated" && + currentPartType === "text" && + rawUpdatedDelta.trim().length > 0 && + rawPartText.trim() === rawUpdatedDelta.trim(); + const isReasoning = + currentPartType === 'reasoning' || + currentStructuredKind === 'thinking' || + isRawDeltaReasoningField || + isDeltaOnlyUpdatedTextSnapshot; // Only emit the "enter reasoning sequence" signal when we first detect a // reasoning part. For raw delta events this fires on every chunk, so guard @@ -8955,7 +9171,13 @@ function handleStreamEvent( if (sanitized) { dispatch({ type: "UPDATE_STREAMING_REASONING", - payload: { reasoning: sanitized, append: true, delta: isDeltaReasoningChunk }, + payload: { + reasoning: sanitized, + append: true, + delta: isDeltaReasoningChunk, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); } } @@ -8972,7 +9194,13 @@ function handleStreamEvent( if (sanitized) { dispatch({ type: "UPDATE_STREAMING_REASONING", - payload: { reasoning: sanitized, append: true, delta: isDeltaReasoningChunk }, + payload: { + reasoning: sanitized, + append: true, + delta: isDeltaReasoningChunk, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); } } @@ -8993,7 +9221,13 @@ function handleStreamEvent( if (sanitized) { dispatch({ type: 'UPDATE_STREAMING_REASONING', - payload: { reasoning: sanitized, append: true, delta: isDeltaReasoningChunk }, + payload: { + reasoning: sanitized, + append: true, + delta: isDeltaReasoningChunk, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); } @@ -9023,7 +9257,13 @@ function handleStreamEvent( if (reasoningLeak) { dispatch({ type: "UPDATE_STREAMING_REASONING", - payload: { reasoning: reasoningLeak, append: true, delta: isDeltaReasoningChunk }, + payload: { + reasoning: reasoningLeak, + append: true, + delta: isDeltaReasoningChunk, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); } dispatchProcessingTrue(); @@ -9035,7 +9275,13 @@ function handleStreamEvent( if (reasoningLeak) { dispatch({ type: "UPDATE_STREAMING_REASONING", - payload: { reasoning: reasoningLeak, append: true, delta: isDeltaReasoningChunk }, + payload: { + reasoning: reasoningLeak, + append: true, + delta: isDeltaReasoningChunk, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); } candidateChunk = mixedChunk.content; @@ -9046,7 +9292,13 @@ function handleStreamEvent( if (reasoningLeak) { dispatch({ type: "UPDATE_STREAMING_REASONING", - payload: { reasoning: reasoningLeak, append: true, delta: isDeltaReasoningChunk }, + payload: { + reasoning: reasoningLeak, + append: true, + delta: isDeltaReasoningChunk, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); } dispatchProcessingTrue(); @@ -9091,7 +9343,12 @@ function handleStreamEvent( if (hasDuplicateTokenPattern(candidateTokens)) { dispatch({ type: "UPDATE_STREAMING_REASONING", - payload: { reasoning: contentPatch.content, append: contentPatch.append }, + payload: { + reasoning: contentPatch.content, + append: contentPatch.append, + partID: reasoningPartID, + messageID: messageId || undefined, + }, }); dispatchProcessingTrue(); break; @@ -9131,6 +9388,10 @@ function handleStreamEvent( } if (partType === 'step-finish' && structuredKind !== 'thinking') { + const activeAssistantMessageIds = [ + getState().streaming?.messageId, + getState().assistantTurnMessageId, + ]; const diffStatsRec = asRecord(part.diffStats); const diffStats = diffStatsRec ? { @@ -9151,7 +9412,13 @@ function handleStreamEvent( duration: asOptionalNumber(asRecord(part.timing)?.duration), diffStats, }); - terminalTurnClosed = true; + // A step-finish is terminal only for the active parent assistant turn. + // Background-agent events may be deeply wrapped; their parentMessageId + // is intentionally accepted by the identity matcher above. + if (completeStreamingTurnFromTerminalEvent(dispatch, getState, payload)) { + markAssistantTurnClosed?.(...activeAssistantMessageIds); + terminalTurnClosed = true; + } } if (partType === 'tool') { @@ -9159,7 +9426,10 @@ function handleStreamEvent( const stateObj = asRecord(part.state); const inputObj = asRecord(stateObj?.input); const partInputObj = asRecord((part as UnknownRecord).input); + const partMetadata = asRecord(stateObj?.metadata) ?? asRecord(part.metadata); + const metadataFileDiff = asRecord(partMetadata?.filediff); const filePath = + asOptionalString(metadataFileDiff?.file) || extractFilePathCandidate(inputObj) || extractFilePathCandidate(partInputObj) || extractFilePathCandidate(stateObj?.result) || @@ -9186,13 +9456,21 @@ function handleStreamEvent( inputObj?.pattern, ); const callID = asString(part.callID) || undefined; + // A tool part's message ID identifies the assistant turn that owns the + // activity. Do not substitute the currently active stream/parent turn + // when the SDK supplied this more specific identity. + const activityMessageID = firstNonEmptyString( + asString(part.messageID), + asString(part.messageId), + messageId, + ) || undefined; const sessionID = asString(part.sessionID) || asString(part.sessionId) || asString((stateObj as UnknownRecord)?.sessionID) || asString((stateObj as UnknownRecord)?.sessionId) || undefined; - const title = asString(part.title) || (tool ? `Running ${tool}...` : inferredStepTitle(part)); + const toolName = tool.trim().toLowerCase(); const normalizedPartStatus = normalizeProgressStatus(asString(part.status)); const normalizedStateStatus = normalizeProgressStatus( asString(stateObj?.status), @@ -9200,28 +9478,47 @@ function handleStreamEvent( const reportedToolStatus = normalizedPartStatus === "error" || normalizedStateStatus === "error" ? "error" - : normalizedPartStatus === "done" || normalizedStateStatus === "done" + : normalizedPartStatus === "done" || + normalizedPartStatus === "completed" || + normalizedStateStatus === "done" || + normalizedStateStatus === "completed" ? "done" : normalizedPartStatus === "running" || normalizedStateStatus === "running" ? "running" : "pending"; + const questionPresentation = completedQuestionToolPresentation( + toolName, + stateObj?.status ?? part.status, + stateObj?.output, + ); + const isCompletedQuestionTool = questionPresentation.isCompleted; + const title = + isCompletedQuestionTool + ? questionPresentation.title! + : asString(part.title) || + asString(stateObj?.title) || + (tool ? `Running ${tool}...` : inferredStepTitle(part)); // Extract output from multiple possible sources // 1. From state.output (tool state) // 2. From metadata.preview (debug metadata) const stateOutput = asOptionalString(stateObj?.output); - const partMetadata = asRecord(part.metadata); const metadataPreview = asOptionalString(partMetadata?.preview); const metadataTruncated = partMetadata?.truncated === true; const finalOutput = stateOutput || metadataPreview; const stateDiffExcerpt = asRecord(stateObj?.diffExcerpt); - const diffExcerpt = stateDiffExcerpt ? { + const explicitDiffExcerpt = stateDiffExcerpt ? { header: asOptionalString(stateDiffExcerpt.header), lines: Array.isArray(stateDiffExcerpt.lines) ? stateDiffExcerpt.lines.map(asString) : [], added: typeof stateDiffExcerpt.added === 'number' ? stateDiffExcerpt.added : undefined, deleted: typeof stateDiffExcerpt.deleted === 'number' ? stateDiffExcerpt.deleted : undefined, } : undefined; + const metadataDiffStats = normalizeDiffStats(metadataFileDiff); + const diffExcerpt = explicitDiffExcerpt ?? diffExcerptFromPatch( + metadataFileDiff?.patch ?? partMetadata?.diff, + metadataDiffStats, + ); // Debug logging to trace data flow if ((tool === 'read' || tool === 'Read') && finalOutput) { @@ -9244,16 +9541,21 @@ function handleStreamEvent( }); } - const baseActivityDetail: ActivityDetail | undefined = tool === "question" - ? undefined - : normalizeActivityDetail({ - kind: "tool_call", + const firstQuestion = asRecord( + Array.isArray(inputObj?.questions) ? inputObj.questions[0] : undefined, + ); + const questionPrompt = asOptionalString(firstQuestion?.question); + const baseActivityDetail: ActivityDetail | undefined = + normalizeActivityDetail({ + kind: isCompletedQuestionTool ? "other" : "tool_call", tool: tool || undefined, command: commandValue, query: queryValue, file: filePath, input: inputObj || partInputObj || undefined, - summary: asOptionalString(part.meta), + summary: isCompletedQuestionTool + ? questionPresentation.summary || questionPresentation.title + : questionPrompt || asOptionalString(part.meta), output: finalOutput, diffExcerpt: diffExcerpt, metadata: metadataTruncated ? { truncated: true } : undefined, @@ -9267,6 +9569,7 @@ function handleStreamEvent( upsertStreamingStep(dispatch, getState, { id: asString(part.id) || undefined, callID, + messageID: activityMessageID, title, type: "tool", status: reportedToolStatus, @@ -9299,6 +9602,7 @@ function handleStreamEvent( upsertStreamingStep(dispatch, getState, { id: asString(part.id) || existing.id, callID, + messageID: activityMessageID || existing.messageID, title, type: "tool", status: resolvedStatus, @@ -9533,6 +9837,10 @@ function handleStreamEvent( bootstrapContext, ); } + const finishBelongsToActiveAssistantTurn = terminalEventMatchesAssistantTurn( + payload, + [getState().streaming?.messageId, getState().assistantTurnMessageId], + ); const shouldStartFreshAssistantTurn = eventRole === "assistant" && !!messageId && @@ -9784,6 +10092,16 @@ function handleStreamEvent( if (finish) { + if (!finishBelongsToActiveAssistantTurn) { + logger.debug("Ignoring terminal event for a different assistant turn", { + messageId, + activeMessageIds: [ + currentStreamingMessageId, + getState().assistantTurnMessageId, + ].filter(Boolean), + }); + break; + } if (markAssistantTurnClosed) { markAssistantTurnClosed( asString(asRecord(payload.info)?.id), @@ -11323,9 +11641,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const plainTextFallbackFinal = asBoolean(asRecord(msg)?.plainTextFallback, false) || asBoolean(asRecord(asRecord(msg)?.info)?.plainTextFallback, false); - const snapshotStreaming = - currentStreaming ?? - (shouldDropMismatchedSnapshot ? null : latestStreamingSnapshot); + // A terminal SDK envelope with its own message ID (such as a task + // tool result) is not a finalization of the card that is currently + // streaming. Keep that card live and do not fold its activity into + // the distinct envelope. + const snapshotStreaming = shouldDropMismatchedSnapshot + ? null + : currentStreaming ?? latestStreamingSnapshot; const hasStreamingSnapshotActivity = hasVisibleStreamingSnapshot(snapshotStreaming); const interactiveEventsInResponse = isMessage(msg) @@ -11490,12 +11812,20 @@ export function createMessageHandler(dispatch: Dispatch, getState: () null; dispatch({ type: "SET_MESSAGES", - payload: replaceMatchingAssistantTurn(currentMessages, sanitized, [ - finalMessageId, - responseMessageId, - streamingMessageId, - snapshotMessageId, - ]), + payload: replaceMatchingAssistantTurn( + currentMessages, + sanitized, + [ + finalMessageId, + responseMessageId, + // A completed SDK envelope with its own parts (for example + // a task tool) owns a distinct message ID. Do not merge it + // into a preceding stream snapshot that has a different ID. + ...(shouldDropMismatchedSnapshot + ? [] + : [streamingMessageId, snapshotMessageId]), + ], + ), }); remapSubagentsToFinalMessageId( dispatch, @@ -11523,8 +11853,8 @@ export function createMessageHandler(dispatch: Dispatch, getState: () isMatchingStreamingMessage || !currentStreaming || !streamingMessageId || - hasOwnResponsePayload || - interactiveEventsInResponse.length > 0; + (!shouldDropMismatchedSnapshot && + (hasOwnResponsePayload || interactiveEventsInResponse.length > 0)); streamDebug("Stream response: completion decision", { responseMessageId: responseMessageId ?? null, finalMessageId, @@ -11578,50 +11908,70 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const sdkContextInputTokens = normalizeTokenCount( (data as UnknownRecord).contextInputTokens, ); - let persistedSubagents: UnknownRecord | undefined; let hydrationPresentationPolicy: SubagentPresentationPolicy = { mode: "hydration", sessionProcessing: false, }; - const cancelIncompleteHydratedSubagents = - (data as UnknownRecord).subagentsRecoveredAfterRestart === true; + const cancelIncompleteHydratedSubagents = false; try { terminalErrorReached = false; - dispatch({ type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" }); - const centralizedMessages = asArray(data.messages, isMessage); - const rawEventStream = asRecord((data as UnknownRecord).rawEventStream); - // Accept the legacy transport field while existing webviews/session - // payloads roll forward. New payloads use rawEventStream.events. - const rawSdkEventPayloads = Array.isArray(rawEventStream?.events) - ? [...(rawEventStream.events as unknown[])] - : Array.isArray((data as UnknownRecord).rawSdkEventPayloads) - ? [...((data as UnknownRecord).rawSdkEventPayloads as unknown[])] - : []; - persistedSubagents = asRecord((data as UnknownRecord).subagents); + const sdkMessages = asArray(data.messages, isMessage); + const rawSdkMessages = asArray( + (data as UnknownRecord).sdkMessages, + (_item): _item is unknown => true, + ); const historySessionId = asString(data.sessionId) || null; if (historySessionId) { logger.setSession(historySessionId); } - // Hydration must render from the centralized messages payload only. - // The raw event tape is still dispatched separately for the debug - // panel and timeline parsing, but it must not be merged back into - // the render list here or we reintroduce duplicate / incomplete turns. - const sourceMessages = centralizedMessages; - if (rawSdkEventPayloads.length > 0) { - dispatch({ - type: "SET_RAW_SDK_EVENT_PAYLOADS", - payload: { - sessionId: historySessionId || asString(data.sessionId) || "", - events: rawSdkEventPayloads, - }, + const currentState = getState(); + const isSameActiveSessionHistory = !!( + historySessionId && currentState.currentSessionId === historySessionId + ); + const hasEmptySdkSnapshot = + sdkMessages.length === 0 && rawSdkMessages.length === 0; + const hasVisibleCurrentTranscript = currentState.messages.length > 0; + const hasLiveEventsForHistorySession = !!( + historySessionId && + (currentState.liveEventStreamBySessionId?.[historySessionId]?.length ?? 0) > 0 + ); + + // A post-stop refresh can race OpenCode's session persistence. An + // empty response for the already-visible session is therefore not + // authoritative: the stop handler has just locked the live + // snapshot into `messages`. Keep that snapshot (and its raw live + // events) until session.messages() returns a real replacement. + // A genuinely new/empty session still passes through because it + // has no current transcript or live event buffer to protect. + const shouldPreserveEmptySameSessionSnapshot = + isSameActiveSessionHistory && + hasEmptySdkSnapshot && + (hasVisibleCurrentTranscript || hasLiveEventsForHistorySession); + if (shouldPreserveEmptySameSessionSnapshot) { + logger.warn("Ignoring empty SDK hydration snapshot for visible session", { + sessionId: historySessionId, + renderedMessageCount: currentState.messages.length, + liveEventCount: + currentState.liveEventStreamBySessionId?.[historySessionId ?? ""]?.length ?? 0, }); + dispatch({ type: "END_SESSION_LOADING" }); + break; } + + // Hydration is now authoritative, so its completed SDK snapshot + // replaces the transient live-event debug buffer atomically. + dispatch({ type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" }); + // Hydration renders the SDK `session.messages()` snapshot only. + const sourceMessages = sdkMessages; const normalizedMessages = sourceMessages .map((msg) => normalizeMessage(msg, null)) .filter((msg): msg is Message => !!msg) .filter((msg) => isRenderableHistoryMessage(msg)); - const messages = - coalesceAdjacentAssistantHistoryMessages(normalizedMessages); + // `session.messages()` returns complete SDK message envelopes. + // Adjacent assistant messages can be distinct turns sharing the + // same parent user message (tool calls followed by a final reply), + // so preserve their SDK message-ID boundary during hydration. + const messages = normalizedMessages; const hydratedMessages = hydrateLegacyInteractiveUserMessages(messages); const dedupedHydratedMessages = dedupeInteractiveUserHydrationMessages(hydratedMessages); @@ -11634,7 +11984,12 @@ export function createMessageHandler(dispatch: Dispatch, getState: () activeSubagentParentMessageIds = new Set(); chatHistorySessionId = asString(data.sessionId); - const currentState = getState(); + if (chatHistorySessionId) { + dispatch({ + type: "SET_SDK_MESSAGES_DEBUG", + payload: { sessionId: chatHistorySessionId, messages: rawSdkMessages }, + }); + } const payloadProcessingSessionIds = asArray( data.processingSessionIds, (item): item is string => typeof item === "string", @@ -11684,22 +12039,12 @@ export function createMessageHandler(dispatch: Dispatch, getState: () dispatch({ type: "SET_STREAMING", payload: null }); dispatch({ type: "SET_PROCESSING", payload: isSessionProcessing }); } - let stabilizedHydratedMessages = dedupedSystemMessages.map((message) => { - // Clear out message-based subagents during rehydration - // All subagent data now comes from centralized events via subagentsByParentMessageId - // This eliminates dual data source confusion and ensures bash tools appear in subagent cards - if (Array.isArray(message.subagents) && message.subagents.length > 0) { - const { subagents, ...messageWithoutSubagents } = message; - return messageWithoutSubagents as Message; - } - return message; - }); - canonicalMessages = stabilizedHydratedMessages; + canonicalMessages = dedupedSystemMessages; if (chatHistorySessionId) { dispatch({ type: "SET_SESSION_ID", payload: chatHistorySessionId }); } - dispatch({ type: "SET_MESSAGES", payload: stabilizedHydratedMessages }); + dispatch({ type: "SET_MESSAGES", payload: canonicalMessages }); } catch (error) { dispatch({ type: "END_SESSION_LOADING" }); throw error; @@ -11746,13 +12091,30 @@ export function createMessageHandler(dispatch: Dispatch, getState: () } dispatch({ type: "CLEAR_SUBAGENTS_FOR_SESSION" }); - const postHydrationState = getState(); - const rawSdkEventPayloads = postHydrationState.rawSdkEventPayloadsBySessionId?.[chatHistorySessionId] ?? []; - - const extractedHydratedSubagents = resolveHydratedSubagentProjection( - rawSdkEventPayloads, - persistedSubagents, + const detailsById = Object.fromEntries( + canonicalMessages.flatMap((message) => + (message.subagents ?? []).map((detail) => [detail.id, detail]), + ), ); + const summariesByParentMessageId = canonicalMessages.reduce< + Record + >((summaries, message) => { + const parentMessageId = getMessageId(message); + const details = message.subagents ?? []; + if (!parentMessageId || details.length === 0) { + return summaries; + } + summaries[parentMessageId] = details.map((detail) => ({ + id: detail.id, + parentMessageId, + parentSessionId: detail.parentSessionId, + agentId: detail.agentId, + status: detail.status, + latestActivity: detail.latestActivity, + })); + return summaries; + }, {}); + const extractedHydratedSubagents = { summariesByParentMessageId, detailsById }; const normalizedHydratedSubagents = normalizeHydratedSubagentMaps( extractedHydratedSubagents.summariesByParentMessageId, @@ -11902,7 +12264,9 @@ export function createMessageHandler(dispatch: Dispatch, getState: () // from persisted messages. Avoid clobbering those restored cards. if (!hasSnapshotSubagents) { // Fallback to centralized events (single source of truth) - const fallback = extractSubagentsFromCentralizedEvents(getState().rawSdkEventPayloads); + const fallback = extractSubagentsFromCentralizedEvents( + getState().streaming?.rawSdkEventPayloads ?? [], + ); const normalizedFallback = normalizeHydratedSubagentMaps( fallback.summariesByParentMessageId, fallback.detailsById, @@ -12130,7 +12494,9 @@ export function createMessageHandler(dispatch: Dispatch, getState: () // from persisted messages. Avoid clobbering those restored cards. if (!hasSnapshotSubagents) { // Fallback to centralized events (single source of truth) - const fallback = extractSubagentsFromCentralizedEvents(getState().rawSdkEventPayloads); + const fallback = extractSubagentsFromCentralizedEvents( + getState().streaming?.rawSdkEventPayloads ?? [], + ); const normalizedFallback = normalizeHydratedSubagentMaps( fallback.summariesByParentMessageId, fallback.detailsById, @@ -12444,6 +12810,15 @@ export function createMessageHandler(dispatch: Dispatch, getState: () asString(payload.sessionID) || asString(asRecord(payload.properties)?.sessionId) || asString(asRecord(payload.properties)?.sessionID); + if (config.debug.showSdkEventDebug) { + dispatch({ + type: "APPEND_LIVE_EVENT_STREAM_DEBUG_BATCH", + payload: { + sessionId: eventSessionId || stateBeforeStreamEvent.currentSessionId, + events: [payload], + }, + }); + } const activeSessionId = stateBeforeStreamEvent.currentSessionId; if (isStoppedSession(eventSessionId, activeSessionId)) { logger.info("[LIVE-EVENT] ignoring late event for stopped session", { @@ -12468,8 +12843,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () streamEventType === "start" || streamEventType === "streamStart"; const canStartVisibleAssistantTurn = streamEventCanStartVisibleAssistantTurn(payload); - const centralizedDisposition = - getCentralizedDebugPayloadDisposition(payload); const liveRoute = routeLiveEventToUi( payload, eventSessionId || activeSessionId || null, @@ -12483,7 +12856,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () logger.info("[LIVE-EVENT] streamEvent received", { streamEventType, - centralizedDisposition, eventSessionId: eventSessionId || null, activeSessionId: activeSessionId || null, hasToast: !!liveToastNotification, @@ -12492,40 +12864,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () toastType: liveToastNotification?.type ?? null, }); - // Persist the raw centralized tape as soon as the stream event is accepted. - // We do this before the visibility gate so the debug tape and hydrated - // session state stay in sync even while the live UI waits for a renderable - // assistant turn. - if ( - centralizedDisposition === "persist" && - streamEventType !== "server.heartbeat" && - !terminalErrorReached - ) { - logger.info("[CENTRALIZED-TAPE][WEBVIEW] append_raw_sdk_event", { - sessionId: eventSessionId || activeSessionId || getState().currentSessionId || null, - eventType: streamEventType, - hasPart: !!asRecord(payload.part) || !!asRecord(asRecord(payload.properties)?.part), - hasProperties: !!asRecord(payload.properties), - }); - dispatch({ - type: "APPEND_RAW_SDK_EVENT_PAYLOAD", - payload: { - sessionId: eventSessionId || activeSessionId || null, - event: payload, - }, - }); - } else if ( - (centralizedDisposition === "excluded-noise" || centralizedDisposition === "live-only") && - streamEventType !== "server.heartbeat" && - !terminalErrorReached - ) { - logger.info("[CENTRALIZED-TAPE][WEBVIEW] skip_raw_sdk_event", { - sessionId: eventSessionId || activeSessionId || getState().currentSessionId || null, - eventType: streamEventType, - disposition: centralizedDisposition, - }); - } - if ( !stateBeforeStreamEvent.isProcessing && !hasConfirmedProcessingSession && @@ -12553,18 +12891,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () latestStreamingSnapshot = streamingBefore; } - // Reset terminal error flag on explicit stream start + // Reset per-turn activity bookkeeping on an explicit stream start. + // Do not clear `stoppedSessionIds` here: OpenCode can emit a late + // continuation start after an abort request. Only a new user message + // is allowed to open that session for rendering again. if (streamEventType === "start" || streamEventType === "streamStart") { terminalErrorReached = false; activeSubagentParentMessageIds = new Set(); - const resumedSessionId = - eventSessionId || - activeSessionId || - getState().currentSessionId || - ""; - if (resumedSessionId) { - stoppedSessionIds.delete(resumedSessionId); - } } if ( @@ -12650,7 +12983,13 @@ export function createMessageHandler(dispatch: Dispatch, getState: () const properties = asRecord(payload.properties); const finish = resolveMessageUpdatedFinishSignal(payload, properties); - if (finish) { + if ( + finish && + terminalEventMatchesAssistantTurn(payload, [ + stateNow.streaming?.messageId, + stateNow.assistantTurnMessageId, + ]) + ) { // Turn off loading state but don't end the AI turn dispatch({ type: "SET_PROCESSING", payload: false }); @@ -12666,7 +13005,77 @@ export function createMessageHandler(dispatch: Dispatch, getState: () case "streamEventBatch": { const events = Array.isArray(data.events) ? data.events : []; const batchStartedAt = performance.now(); - const rawEventsBySessionId = new Map(); + if (config.debug.showSdkEventDebug) { + const eventsBySessionId = new Map(); + const fallbackSessionId = getState().currentSessionId; + for (const item of events) { + const event = asRecord(item.event) ?? item; + const eventSessionId = + asString(item.sessionId) || + asString(item.sessionID) || + asString(event.sessionId) || + asString(event.sessionID) || + asString(asRecord(event.properties)?.sessionId) || + asString(asRecord(event.properties)?.sessionID) || + fallbackSessionId; + if (!eventSessionId) { + continue; + } + const bucket = eventsBySessionId.get(eventSessionId) ?? []; + bucket.push(event); + eventsBySessionId.set(eventSessionId, bucket); + } + for (const [sessionId, rawEvents] of eventsBySessionId) { + dispatch({ + type: "APPEND_LIVE_EVENT_STREAM_DEBUG_BATCH", + payload: { sessionId, events: rawEvents }, + }); + } + } + // Perf rationale: React's useReducer dispatch commits state + // asynchronously — stateRef.current only updates on the next render + // commit, so a post-dispatch getState() snapshot can return + // pre-batch state. Combined with startTransition deferral below, + // reading getState() here produces false-zero + // streamingContentLength / streamingReasoningLength readings in the + // perf metric. trackedDispatch mirrors the reducer's append/replace + // logic on the dispatched payloads so the metric reflects post-batch + // state regardless of commit timing. Removing this accumulator (or + // emitting the metric outside processBatchEvents) will reintroduce + // the false-zero streaming-perf reports documented in + // tests/webview/stream-performance-debug.test.mjs. + const stateAtBatchStart = getState(); + let trackedStreamingContentLength = + stateAtBatchStart.streaming?.content?.length ?? 0; + let trackedStreamingReasoningLength = + stateAtBatchStart.streaming?.reasoning?.length ?? 0; + const trackedDispatch: Dispatch = (action) => { + if (action && action.type === "UPDATE_STREAMING_CONTENT" && action.payload) { + const incomingLen = + typeof action.payload.content === "string" + ? action.payload.content.length + : 0; + trackedStreamingContentLength = action.payload.append + ? trackedStreamingContentLength + incomingLen + : incomingLen; + } else if ( + action && + action.type === "UPDATE_STREAMING_REASONING" && + action.payload + ) { + const incomingLen = + typeof action.payload.reasoning === "string" + ? action.payload.reasoning.length + : 0; + // Reasoning reducer performs dedupe/merge that can shrink the + // resulting string below a naive concat; this upper-bound length + // is sufficient for diagnostic signal. + trackedStreamingReasoningLength = action.payload.append + ? trackedStreamingReasoningLength + incomingLen + : incomingLen; + } + dispatch(action); + }; const processBatchEvents = () => { for (const [eventIndex, item] of events.entries()) { const evtPayload = asRecord(item.event) ?? item; @@ -12689,21 +13098,12 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }); continue; } - const disposition = getCentralizedDebugPayloadDisposition(evtPayload); routeLiveEventToUi( evtPayload, eventSessionId || activeSessionId || null, "streamEventBatch", eventIndex, ); - if (disposition === "persist" && evtType !== "server.heartbeat") { - const rawSessionId = eventSessionId || activeSessionId || null; - if (rawSessionId) { - const queued = rawEventsBySessionId.get(rawSessionId) ?? []; - queued.push(evtPayload); - rawEventsBySessionId.set(rawSessionId, queued); - } - } if ( eventSessionId && activeSessionId && @@ -12754,7 +13154,7 @@ export function createMessageHandler(dispatch: Dispatch, getState: () }, }); } else { - handleStreamEvent(dispatch, getState, evtPayload, terminalErrorReached, false, markAssistantTurnClosed); + handleStreamEvent(trackedDispatch, getState, evtPayload, terminalErrorReached, false, markAssistantTurnClosed); } if ( @@ -12766,17 +13166,25 @@ export function createMessageHandler(dispatch: Dispatch, getState: () evtPayload, asRecord(evtPayload.properties), ); - if (finish) { + if ( + finish && + terminalEventMatchesAssistantTurn(evtPayload, [ + getState().streaming?.messageId, + getState().assistantTurnMessageId, + ]) + ) { dispatch({ type: "SET_PROCESSING", payload: false }); } } } - for (const [sessionId, rawEvents] of rawEventsBySessionId) { - dispatch({ - type: "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH", - payload: { sessionId, events: rawEvents }, - }); - } + // Emit inside processBatchEvents so the tracked accumulators + // have observed every dispatched payload before the read. + logger.streamPerformance("stream-event-batch", { + eventCount: events.length, + durationMs: Number((performance.now() - batchStartedAt).toFixed(2)), + streamingContentLength: trackedStreamingContentLength, + streamingReasoningLength: trackedStreamingReasoningLength, + }); }; const hasImmediateActivity = events.some((item) => isImmediateActivityStreamPayload(asRecord(item.event) ?? item), @@ -12790,12 +13198,6 @@ export function createMessageHandler(dispatch: Dispatch, getState: () if (streamingAfter) { latestStreamingSnapshot = streamingAfter; } - logger.streamPerformance("stream-event-batch", { - eventCount: events.length, - durationMs: Number((performance.now() - batchStartedAt).toFixed(2)), - streamingContentLength: streamingAfter?.content.length ?? 0, - streamingReasoningLength: streamingAfter?.reasoning.length ?? 0, - }); break; } case "streamEventEnrich": { diff --git a/webview/shared/src/chat/lib/pendingUserMessages.ts b/webview/shared/src/chat/lib/pendingUserMessages.ts index 2539846..2184e7b 100644 --- a/webview/shared/src/chat/lib/pendingUserMessages.ts +++ b/webview/shared/src/chat/lib/pendingUserMessages.ts @@ -170,6 +170,31 @@ export function pendingUserMessageToMessage(pending: PendingUserMessage): Messag filename: attachment.filename, url: attachment.dataUrl, })), + ...(pending.contexts ?? []) + .filter((context) => typeof context.file === "string" && context.file.trim().length > 0) + .map((context) => ({ + type: "file" as const, + mime: context.languageId || "text/plain", + filename: context.file.split(/[\\/]/).pop(), + source: { + type: context.file.startsWith("resource:") ? "resource" : "file", + path: context.file.startsWith("resource:") ? undefined : context.file, + uri: context.file.startsWith("resource:") + ? context.file.replace("resource:", "") + : undefined, + lineInfo: context.lineInfo || undefined, + languageId: context.languageId || undefined, + ...(context.content + ? { + text: { + value: context.content, + start: 0, + end: context.content.length, + }, + } + : {}), + }, + })), ], images: pending.images, attachments: pending.attachments, diff --git a/webview/shared/src/chat/lib/renderContract.ts b/webview/shared/src/chat/lib/renderContract.ts new file mode 100644 index 0000000..d27563c --- /dev/null +++ b/webview/shared/src/chat/lib/renderContract.ts @@ -0,0 +1,89 @@ +import type { Message, StreamingState } from "./types"; + +/** + * The only source-aware boundary for chat rendering. Raw SDK payloads are + * interpreted before this point; all UI code consumes these blocks instead. + */ +export type RenderBlock = + | { kind: "message"; source: "live" | "rehydrated"; message: Message } + | { + kind: "fileChanges"; + source: "live" | "rehydrated"; + ownerMessageId: string; + message: Message; + } + | { kind: "streaming"; source: "live"; streaming: StreamingState }; + +type RenderBlockKind = RenderBlock["kind"]; +type BlockOfKind = Extract; + +export type RehydratedRenderSource = { + kind: "rehydrated"; + messages: Message[]; +}; + +export type LiveRenderSource = { + kind: "live"; + messages: Message[]; + streaming: StreamingState | null | undefined; +}; + +/** + * Adding a RenderBlock kind makes TypeScript require an explicit mapping for + * both source shapes. A source may return no blocks of a kind, but that is a + * deliberate, typed decision rather than an accidental UI omission. + */ +type SourceBlockBuilders = { + [K in RenderBlockKind]: (source: TSource) => BlockOfKind[]; +}; + +function messageBlocks( + source: "live" | "rehydrated", + messages: Message[], +): BlockOfKind<"message">[] { + return messages.map((message) => ({ kind: "message", source, message })); +} + +function fileChangeBlocks( + source: "live" | "rehydrated", + messages: Message[], +): BlockOfKind<"fileChanges">[] { + return messages.flatMap((message) => { + const ownerMessageId = message.changeSummary?.messageId ?? message.info?.id ?? message.id; + return message.changeSummary && ownerMessageId + ? [{ kind: "fileChanges", source, ownerMessageId, message }] + : []; + }); +} + +const rehydratedBuilders = { + message: ({ messages }) => messageBlocks("rehydrated", messages), + fileChanges: ({ messages }) => fileChangeBlocks("rehydrated", messages), + streaming: () => [], +} satisfies SourceBlockBuilders; + +const liveBuilders = { + message: ({ messages }) => messageBlocks("live", messages), + fileChanges: ({ messages }) => fileChangeBlocks("live", messages), + streaming: ({ streaming }) => + streaming ? [{ kind: "streaming", source: "live", streaming }] : [], +} satisfies SourceBlockBuilders; + +export function buildRenderBlocks(source: RehydratedRenderSource): RenderBlock[]; +export function buildRenderBlocks(source: LiveRenderSource): RenderBlock[]; +export function buildRenderBlocks( + source: RehydratedRenderSource | LiveRenderSource, +): RenderBlock[] { + const builders = source.kind === "live" ? liveBuilders : rehydratedBuilders; + // The caller owns placement. Builders remain source-specific, while every + // rendered value has the same discriminated block contract. + return [ + ...builders.message(source as never), + ...builders.fileChanges(source as never), + ...builders.streaming(source as never), + ]; +} + +export function assertNever(value: never): never { + throw new Error(`Unhandled render block: ${JSON.stringify(value)}`); +} diff --git a/webview/shared/src/chat/lib/store.test.ts b/webview/shared/src/chat/lib/store.test.ts index 8e0469c..59ef651 100644 --- a/webview/shared/src/chat/lib/store.test.ts +++ b/webview/shared/src/chat/lib/store.test.ts @@ -1403,16 +1403,33 @@ describe('canonicalizeMessagesForRender', () => { assert.strictEqual(result[1].role, 'assistant'); }); - it('should coalesce back-to-back assistant messages into a single burst', () => { + it('preserves back-to-back SDK assistant messages as separate envelopes', () => { const messages: Message[] = [ { role: 'user', content: 'hi' }, { role: 'assistant', content: 'thought...', id: 'a1' }, { role: 'assistant', content: 'reply!', id: 'a2' } ]; const result = canonicalizeMessagesForRender(messages); - assert.strictEqual(result.length, 2); - assert.strictEqual(result[1].content, 'reply!'); - assert.strictEqual(result[1].id, 'a2'); + assert.strictEqual(result.length, 3); + assert.strictEqual(result[1].content, 'thought...'); + assert.strictEqual(result[1].id, 'a1'); + assert.strictEqual(result[2].content, 'reply!'); + assert.strictEqual(result[2].id, 'a2'); + }); + + it('keeps an intermediate edit activity before the final assistant response', () => { + const messages: Message[] = [ + { role: 'user', content: 'edit package.json', id: 'u1', created: 1 }, + { role: 'assistant', id: 'a-read', created: 2, steps: [{ type: 'tool', title: 'read' }] }, + { role: 'assistant', id: 'a-edit', created: 3, steps: [{ type: 'tool', title: 'edit' }] }, + { role: 'assistant', id: 'a-final', created: 4, content: 'Done.' }, + ]; + + const result = canonicalizeMessagesForRender(messages); + + assert.deepStrictEqual(result.map((message) => message.id), [ + 'u1', 'a-read', 'a-edit', 'a-final', + ]); }); it('should collapse an immediately repeated user+assistant turn', () => { diff --git a/webview/shared/src/chat/lib/store.ts b/webview/shared/src/chat/lib/store.ts index 922088f..2d8022a 100644 --- a/webview/shared/src/chat/lib/store.ts +++ b/webview/shared/src/chat/lib/store.ts @@ -1,5 +1,6 @@ import React, { createContext, + useCallback, useContext, useLayoutEffect, useMemo, @@ -8,6 +9,7 @@ import React, { useSyncExternalStore, } from 'react'; import logger from './logger'; +import { perfProbe } from './streamingPerfProbe'; import { PENDING_CURRENT_SESSION_KEY } from "./pendingUserMessages"; import { getCentralizedAssistantContentFromRawSdkEventPayloads, @@ -78,7 +80,7 @@ export const initialState: AppState = { currentSessionId: null, messages: [], messagesBySessionId: {}, - rawSdkEventPayloadsBySessionId: {}, + sdkMessagesBySessionId: {}, liveEventStreamBySessionId: {}, liveToastNotificationsBySessionId: {}, promptQueue: [], @@ -197,16 +199,9 @@ export type AppAction = | { type: "SET_SELECTED_AGENT"; payload: string } | { type: "SET_AGENTS_LIST"; payload: Agent[] } | { type: "SET_MESSAGES"; payload: Message[] } - | { - type: "SET_RAW_SDK_EVENT_PAYLOADS"; - payload: { sessionId: string; events: unknown[] }; - } - | { type: "APPEND_RAW_SDK_EVENT_PAYLOAD"; payload: { sessionId?: string | null; event: unknown } } - | { - type: "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH"; - payload: { sessionId?: string | null; events: unknown[] }; - } + | { type: "SET_SDK_MESSAGES_DEBUG"; payload: { sessionId: string; messages: unknown[] } } | { type: "APPEND_LIVE_EVENT_STREAM_DEBUG"; payload: { sessionId?: string | null; event: unknown } } + | { type: "APPEND_LIVE_EVENT_STREAM_DEBUG_BATCH"; payload: { sessionId?: string | null; events: unknown[] } } | { type: "CLEAR_LIVE_EVENT_STREAM_DEBUG" } | { type: "APPEND_LIVE_TOAST_NOTIFICATION"; @@ -844,6 +839,40 @@ function mergeStreamingSnapshotLocal( !!normalizedIncoming.messageId && existing.messageId !== normalizedIncoming.messageId; if (!existing || shouldResetTimeline) { + // Multi-phase turn detection (OpenCode emits multiple SDK assistant + // message envelopes — reasoning, tool-call, final answer — within a + // single user-visible turn, each with a different messageId). Each phase + // gets its own message.completed with a finish signal BEFORE the next + // phase begins, so checking hasAssistantFinishSignal here is wrong — + // it would skip preservation in exactly the case where it's needed. + // + // This branch only fires when shouldResetTimeline is true, which means + // existing snapshot exists AND messageId changed. Truly new turns are + // handled by the user-message handler which clears state.streaming + // BEFORE the next assistant message.updated arrives, so by the time + // shouldStartFreshAssistantTurn fires (messageHandler.ts:9722-9727) + // we are inherently in a mid-turn phase transition. Preserve the + // accumulated timeline arrays so the activity timeline does not + // visually reset to empty. Project rule: "we don't clear the rendered UI". + if (shouldResetTimeline) { + return { + ...normalizedIncoming, + // Preserve turn-level activity (accumulates across messages in a multi-phase turn) + steps: existing?.steps ?? normalizedIncoming.steps, + progressEvents: existing?.progressEvents ?? normalizedIncoming.progressEvents, + reasoningEvents: existing?.reasoningEvents ?? normalizedIncoming.reasoningEvents, + edits: existing?.edits ?? normalizedIncoming.edits, + interactiveEvents: existing?.interactiveEvents ?? normalizedIncoming.interactiveEvents, + // Preserve renderable flag so ThinkingBubble does not re-appear mid-turn + hasRenderableContent: + existing?.hasRenderableContent || + normalizedIncoming.hasRenderableContent || + false, + hasAssistantFinishSignal: false, + hasTerminalStepSignal: false, + liveSessionStatus: normalizedIncoming.liveSessionStatus ?? null, + }; + } return { ...normalizedIncoming, hasRenderableContent: normalizedIncoming.hasRenderableContent ?? false, @@ -2217,31 +2246,12 @@ export function canonicalizeMessagesForRender( const deduped = dedupeMirrorMessagesForCanonical(chronologicallyOrdered); const dedupedTurns = dedupeAdjacentCanonicalTurns(deduped); - const canonical: Message[] = []; - let index = 0; - - while (index < dedupedTurns.length) { - const current = dedupedTurns[index]; - const isAssistant = isAssistantMessageForCanonical(current); - - if (!isAssistant) { - canonical.push(current); - index += 1; - continue; - } - const burst: Message[] = [current]; - let cursor = index + 1; - while (cursor < dedupedTurns.length && isAssistantMessageForCanonical(dedupedTurns[cursor])) { - burst.push(dedupedTurns[cursor]); - cursor += 1; - } - canonical.push( - burst.length === 1 ? current : coalesceAssistantRunForCanonical(burst), - ); - index = cursor; - } - - return canonical; + // `session.messages()` already returns complete, ordered SDK message + // envelopes. Consecutive assistant envelopes are meaningful phases (for + // example read → edit → final response), not duplicate fragments. Identity + // deduplication above removes true duplicates; never coalesce the remaining + // assistant messages here or their activity is moved into the final card. + return dedupedTurns; } const MAX_STREAMING_REASONING_EVENTS = 300; @@ -2454,11 +2464,18 @@ export function mergeStreamingReasoning( // normalizing and searching the entire accumulated reasoning buffer for // every token; that made long reasoning streams progressively quadratic // and could monopolize the webview thread during scroll gestures. + // + // Delta semantics: a delta is a continuation of the SAME reasoning stream, + // not a new step. The reducer must APPEND each delta chunk to the most + // recent reasoning event rather than creating a new entry. Returning + // replaceLastEvent:false here would let findStreamingReasoningEventIndex + // miss the match (per-chunk-unique partID OR multiple events under same + // messageID) and split the stream into cascading duplicate steps. if (delta) { return { reasoning: appendStreamingReasoning(current, incoming), eventChunk: incomingChunk, - replaceLastEvent: false, + replaceLastEvent: true, }; } @@ -2834,19 +2851,6 @@ export function appReducer(state: AppState, action: AppAction): AppState { if (areCanonicalMessagesEquivalent(state.messages, canonicalMessages)) { return state; } - const attachedRawSdkEventPayloads = canonicalMessages.reduce( - (collected, message) => { - const payloads = Array.isArray( - (message as unknown as Record).rawSdkEventPayloads, - ) - ? ((message as unknown as Record).rawSdkEventPayloads as unknown[]) - : []; - return payloads.length > 0 - ? mergeRawSdkEventPayloads(collected, payloads) - : collected; - }, - [], - ); const resolvedDividerIndex = resolveCompactionDividerIndex(canonicalMessages, { compactionDividerIndex: state.compactionDividerIndex, compactionDividerBeforeMessageId: state.compactionDividerBeforeMessageId, @@ -2885,28 +2889,10 @@ export function appReducer(state: AppState, action: AppAction): AppState { state.dismissedInteractiveEventKeys, ) : derivedInteractiveEvents; - const nextRawSdkEventPayloadsBySessionId = - state.currentSessionId && attachedRawSdkEventPayloads.length > 0 - ? pruneSessionCache({ - ...(state.rawSdkEventPayloadsBySessionId ?? {}), - [state.currentSessionId]: dedupeCentralizedDebugPayloads( - shouldIncludeCentralizedDebugPayload - ? mergeRawSdkEventPayloads( - state.rawSdkEventPayloadsBySessionId?.[state.currentSessionId] ?? [], - attachedRawSdkEventPayloads, - ).filter(shouldIncludeCentralizedDebugPayload) - : mergeRawSdkEventPayloads( - state.rawSdkEventPayloadsBySessionId?.[state.currentSessionId] ?? [], - attachedRawSdkEventPayloads, - ), - ), - }, state.currentSessionId) - : state.rawSdkEventPayloadsBySessionId; return { ...state, messages: canonicalMessages, interactiveEvents: nextInteractiveEvents, - rawSdkEventPayloadsBySessionId: nextRawSdkEventPayloadsBySessionId, compactionDividerIndex: resolvedDividerIndex, compactionDividerBeforeMessageId: resolvedAnchors.compactionDividerBeforeMessageId, @@ -2914,121 +2900,46 @@ export function appReducer(state: AppState, action: AppAction): AppState { resolvedAnchors.compactionDividerAfterMessageId, }; } - case "SET_RAW_SDK_EVENT_PAYLOADS": { - const rawEvents = Array.isArray(action.payload.events) ? action.payload.events : []; - const events = rawEvents - .filter(shouldIncludeCentralizedDebugPayload) - .map((event) => sanitizeCentralizedDebugPayload(event)); - const existingEvents = state.rawSdkEventPayloadsBySessionId?.[action.payload.sessionId]; - if (areCentralizedEventListsEquivalent(existingEvents, events)) { - return state; - } - logger.info("[CENTRALIZED-TAPE][WEBVIEW_STORE] set_raw_sdk_event_payloads", { - sessionId: action.payload.sessionId, - incomingCount: rawEvents.length, - storedCount: events.length, - currentSessionId: state.currentSessionId, - }); + case "SET_SDK_MESSAGES_DEBUG": { return { ...state, - rawSdkEventPayloadsBySessionId: pruneSessionCache({ - ...(state.rawSdkEventPayloadsBySessionId ?? {}), - [action.payload.sessionId]: events, - }, action.payload.sessionId), + sdkMessagesBySessionId: { + ...(state.sdkMessagesBySessionId ?? {}), + [action.payload.sessionId]: action.payload.messages, + }, }; } - case "APPEND_RAW_SDK_EVENT_PAYLOAD": { + case "APPEND_LIVE_EVENT_STREAM_DEBUG": { const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; if (!sessionId) { - logger.warn("[CENTRALIZED-TAPE][WEBVIEW_STORE] append_raw_sdk_event_payload_skipped", { - reason: "missing-session", - currentSessionId: state.currentSessionId, - }); return state; } - if (!shouldIncludeCentralizedDebugPayload(action.payload.event)) { - const event = asRecordLocal(action.payload.event); - logger.warn("[CENTRALIZED-TAPE][WEBVIEW_STORE] append_raw_sdk_event_payload_skipped", { - reason: "filtered", - sessionId, - eventType: asStringLocal(event?.type), - currentSessionId: state.currentSessionId, - }); - return state; - } - const existing = state.rawSdkEventPayloadsBySessionId?.[sessionId] ?? []; - const sanitizedEvent = sanitizeCentralizedDebugPayload(action.payload.event); - const next = appendAndDedupeCentralizedDebugPayload(existing, sanitizedEvent) as unknown[]; - const event = asRecordLocal(sanitizedEvent); - const properties = asRecordLocal(event?.properties); - const info = asRecordLocal(properties?.info); - const part = asRecordLocal(properties?.part); - logger.info("[CENTRALIZED-TAPE][WEBVIEW_STORE] append_raw_sdk_event_payload", { - sessionId, - eventType: asStringLocal(event?.type), - source: asStringLocal(event?.source), - messageId: asStringLocal(info?.id, part?.messageId, part?.messageID), - partType: asStringLocal(part?.type), - previousCount: existing.length, - nextCount: next.length, - deduped: next.length === existing.length, - currentSessionId: state.currentSessionId, - }); - return { - ...state, - rawSdkEventPayloadsBySessionId: pruneSessionCache({ - ...(state.rawSdkEventPayloadsBySessionId ?? {}), - [sessionId]: next, - }, sessionId), - }; - } - case "APPEND_RAW_SDK_EVENT_PAYLOAD_BATCH": { - const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; - const incoming = Array.isArray(action.payload.events) - ? action.payload.events.filter(shouldIncludeCentralizedDebugPayload) - : []; - if (!sessionId || incoming.length === 0) { - return state; - } - const existing = state.rawSdkEventPayloadsBySessionId?.[sessionId] ?? []; - let next = existing; - for (const rawEvent of incoming) { - next = appendAndDedupeCentralizedDebugPayload( - next, - sanitizeCentralizedDebugPayload(rawEvent), - ) as unknown[]; - } - if (next === existing) { - return state; - } - logger.info("[CENTRALIZED-TAPE][WEBVIEW_STORE] append_raw_sdk_event_payload_batch", { - sessionId, - incomingCount: incoming.length, - previousCount: existing.length, - nextCount: next.length, - currentSessionId: state.currentSessionId, - }); return { ...state, - rawSdkEventPayloadsBySessionId: pruneSessionCache({ - ...(state.rawSdkEventPayloadsBySessionId ?? {}), - [sessionId]: next, - }, sessionId), + liveEventStreamBySessionId: { + ...(state.liveEventStreamBySessionId ?? {}), + [sessionId]: [ + ...(state.liveEventStreamBySessionId?.[sessionId] ?? []), + action.payload.event, + ], + }, }; } - case "APPEND_LIVE_EVENT_STREAM_DEBUG": { + case "APPEND_LIVE_EVENT_STREAM_DEBUG_BATCH": { const sessionId = action.payload.sessionId ?? state.currentSessionId ?? ""; - if (!sessionId) { + const events = Array.isArray(action.payload.events) ? action.payload.events : []; + if (!sessionId || events.length === 0) { return state; } + // Debug data is browser-lifetime only. Keep a generous bound so it is + // useful to copy, without allowing a long stream to grow unbounded. + const current = state.liveEventStreamBySessionId?.[sessionId] ?? []; + const next = [...current, ...events].slice(-2_000); return { ...state, liveEventStreamBySessionId: { ...(state.liveEventStreamBySessionId ?? {}), - [sessionId]: [ - ...(state.liveEventStreamBySessionId?.[sessionId] ?? []), - action.payload.event, - ], + [sessionId]: next, }, }; } @@ -3407,22 +3318,10 @@ export function appReducer(state: AppState, action: AppAction): AppState { } const existing = state.streaming.rawSdkEventPayloads ?? []; const next = appendAndDedupeCentralizedDebugPayload(existing, action.payload) as unknown[]; - const nextRawSdkEventPayloadsBySessionId = - state.currentSessionId - ? pruneSessionCache({ - ...(state.rawSdkEventPayloadsBySessionId ?? {}), - [state.currentSessionId]: appendAndDedupeCentralizedDebugPayload(state.rawSdkEventPayloadsBySessionId?.[state.currentSessionId] ?? [], action.payload), - }, state.currentSessionId) - : state.rawSdkEventPayloadsBySessionId; - // NOTE: Cap the per-turn streaming snapshot at 200 entries to bound memory, - // but always continue updating the session-scoped cache. The session cache - // is the long-term centralized tape; dropping events from it causes the - // activity timeline and subagent rehydration to go blank after the cap. + // Preserve raw SDK events only for the current live overlay. This bounded + // snapshot is discarded when the authoritative SDK history refresh arrives. if (next.length >= 200) { - return { - ...state, - rawSdkEventPayloadsBySessionId: nextRawSdkEventPayloadsBySessionId, - }; + return state; } return { ...state, @@ -3430,7 +3329,6 @@ export function appReducer(state: AppState, action: AppAction): AppState { ...state.streaming, rawSdkEventPayloads: next, }, - rawSdkEventPayloadsBySessionId: nextRawSdkEventPayloadsBySessionId, }; } case "UPDATE_STREAMING_CONTENT": { @@ -3440,20 +3338,6 @@ export function appReducer(state: AppState, action: AppAction): AppState { const content = action.payload.append ? `${state.streaming.content}${action.payload.content}` : action.payload.content; - if (action.payload.content && action.payload.content.length > 0) { - logger.info('[STORE:UPDATE_STREAMING_CONTENT]', { - append: action.payload.append, - incoming: String(action.payload.content).slice(0, 200), - incomingLen: String(action.payload.content).length, - priorContent: String(state.streaming.content).slice(0, 200), - priorContentLen: String(state.streaming.content).length, - newContent: content.slice(0, 200), - newContentLen: content.length, - renderable: !!action.payload.renderable, - reasoning: String(state.streaming.reasoning).slice(0, 200), - inReasoningPart: state.streaming.inReasoningPart, - }); - } // Record the first moment non-empty content arrives so the timeline can order it correctly const contentStartSeq = state.streaming.contentStartSeq !== undefined @@ -3482,6 +3366,21 @@ export function appReducer(state: AppState, action: AppAction): AppState { ) { return state; } + // Perf: log only on actual state change — per-batch logging here caused main-thread saturation during streaming. + if (action.payload.content && action.payload.content.length > 0) { + logger.info('[STORE:UPDATE_STREAMING_CONTENT]', { + append: action.payload.append, + incoming: String(action.payload.content).slice(0, 200), + incomingLen: String(action.payload.content).length, + priorContent: String(state.streaming.content).slice(0, 200), + priorContentLen: String(state.streaming.content).length, + newContent: content.slice(0, 200), + newContentLen: content.length, + renderable: !!action.payload.renderable, + reasoning: String(state.streaming.reasoning).slice(0, 200), + inReasoningPart: state.streaming.inReasoningPart, + }); + } const streaming = { ...state.streaming, content, @@ -4470,6 +4369,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) { const rafIdRef = useRef(null); stateRef.current = state; + // Perf probe: wrap dispatch so we can attribute per-action-type cost during + // streaming. Zero overhead when probe is disabled. + const timedDispatch = useCallback>((action) => { + if (!perfProbe.isEnabled()) { + dispatch(action); + return; + } + const start = performance.now(); + dispatch(action); + perfProbe.recordDispatch((action as { type?: unknown })?.type, performance.now() - start); + }, []); + useLayoutEffect(() => { const isStreaming = stateRef.current.streaming?.isActive; @@ -4506,7 +4417,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { return React.createElement( AppStoreContext.Provider, { value: store }, - React.createElement(AppDispatchContext.Provider, { value: dispatch }, children), + React.createElement(AppDispatchContext.Provider, { value: timedDispatch }, children), ); } diff --git a/webview/shared/src/chat/lib/streamingPerfProbe.ts b/webview/shared/src/chat/lib/streamingPerfProbe.ts new file mode 100644 index 0000000..c3b5fad --- /dev/null +++ b/webview/shared/src/chat/lib/streamingPerfProbe.ts @@ -0,0 +1,194 @@ +/** + * Lightweight perf probe for streaming-latency investigation. + * + * Enable from DevTools console: + * (window).__OC_PERF_PROBE__ = true + * + * Disable: + * (window).__OC_PERF_PROBE__ = false + * + * Reports (every 2s while enabled): + * - Per-action-type dispatch timing (count / avg / max) from the reducer + * - Render+commit timing for ChatContent + * - Immediate `SLOW` warnings for any single op > 16ms (one frame budget) + * + * Zero overhead when disabled — every entry point checks isEnabled() first. + * + * Why a dedicated probe vs React DevTools Profiler: + * React Profiler captures render times but misses reducer work that + * happens between dispatch and state-change notification (especially + * inside the rAF-batched listener path in AppProvider). It also can't + * isolate which action TYPES are hot. This probe answers both questions + * with timestamps taken at the same layer where the lag originates. + */ + +const isBrowser = typeof window !== 'undefined'; +const hasPerf = typeof performance !== 'undefined'; + +const enabled = (): boolean => + isBrowser && (window as unknown as { __OC_PERF_PROBE__?: boolean }).__OC_PERF_PROBE__ === true; + +const now = (): number => (hasPerf ? performance.now() : Date.now()); + +interface Bucket { + count: number; + totalMs: number; + maxMs: number; + slow: number; +} + +const dispatchBuckets = new Map(); +const messageBuckets = new Map(); +let renderCount = 0; +let renderTotalMs = 0; +let renderMaxMs = 0; +let renderSlowCount = 0; +let lastFlushAt = now(); +let nextFlushScheduled = false; + +const SLOW_THRESHOLD_MS = 8; // half a frame +const FRAME_BUDGET_MS = 16; // one frame at 60fps +const FLUSH_INTERVAL_MS = 2000; + +function ensureFlushScheduled(): void { + if (nextFlushScheduled) return; + nextFlushScheduled = true; + setTimeout(flushReport, FLUSH_INTERVAL_MS); +} + +function colorForMs(ms: number): string { + if (ms > FRAME_BUDGET_MS) return '#ef4444'; // red — blew a frame + if (ms > SLOW_THRESHOLD_MS) return '#f59e0b'; // amber — getting close + return '#22c55e'; // green — fine +} + +function flushReport(): void { + nextFlushScheduled = false; + const elapsed = now() - lastFlushAt; + lastFlushAt = now(); + + const dispatchEntries = [...dispatchBuckets.entries()].sort( + (a, b) => b[1].totalMs - a[1].totalMs, + ); + const messageEntries = [...messageBuckets.entries()].sort( + (a, b) => b[1].totalMs - a[1].totalMs, + ); + + if (dispatchEntries.length === 0 && messageEntries.length === 0 && renderCount === 0) { + return; // nothing to report — avoid noise + } + + if (typeof console === 'undefined' || !console.groupCollapsed) return; + + console.groupCollapsed( + `%c[OC PERFL ${elapsed.toFixed(0)}ms window]`, + 'color: #fb923c; font-weight: bold;', + ); + + if (renderCount > 0) { + const avg = renderTotalMs / renderCount; + const color = colorForMs(avg); + console.log( + `%c RENDER count=${renderCount} avg=${avg.toFixed(2)}ms max=${renderMaxMs.toFixed(2)}ms slow=${renderSlowCount}`, + `color: ${color}; font-weight: bold;`, + ); + } + + if (dispatchEntries.length > 0) { + console.log('%c DISPATCH (sorted by total time):', 'color: #93c5fd;'); + for (const [type, b] of dispatchEntries) { + const avg = b.totalMs / b.count; + const color = colorForMs(avg); + const flag = b.slow > 0 ? ` ⚠slow=${b.slow}` : ''; + console.log( + `%c ${type.padEnd(40)} n=${String(b.count).padStart(4)} avg=${avg.toFixed(2).padStart(7)}ms max=${b.maxMs.toFixed(2).padStart(7)}ms${flag}`, + `color: ${color};`, + ); + } + } + + if (messageEntries.length > 0) { + console.log('%c MESSAGE handler (sorted by total time):', 'color: #93c5fd;'); + for (const [type, b] of messageEntries) { + const avg = b.totalMs / b.count; + const color = colorForMs(avg); + console.log( + `%c ${type.padEnd(40)} n=${String(b.count).padStart(4)} avg=${avg.toFixed(2).padStart(7)}ms max=${b.maxMs.toFixed(2).padStart(7)}ms`, + `color: ${color};`, + ); + } + } + + console.groupEnd(); + + dispatchBuckets.clear(); + messageBuckets.clear(); + renderCount = 0; + renderTotalMs = 0; + renderMaxMs = 0; + renderSlowCount = 0; +} + +function record( + buckets: Map, + key: string, + durationMs: number, + warnLabel?: string, +): void { + const k = String(key).slice(0, 48); + let bucket = buckets.get(k); + if (!bucket) { + bucket = { count: 0, totalMs: 0, maxMs: 0, slow: 0 }; + buckets.set(k, bucket); + } + bucket.count += 1; + bucket.totalMs += durationMs; + if (durationMs > bucket.maxMs) bucket.maxMs = durationMs; + if (durationMs > SLOW_THRESHOLD_MS) bucket.slow += 1; + if (durationMs > FRAME_BUDGET_MS && warnLabel && typeof console !== 'undefined' && console.warn) { + console.warn( + `%c[OC PERFL FRAME-BLOW] ${warnLabel}: ${k} = ${durationMs.toFixed(1)}ms`, + 'color: #ef4444; font-weight: bold;', + ); + } + ensureFlushScheduled(); +} + +export const perfProbe = { + isEnabled: enabled, + + /** Time a single dispatch call. Call from the wrapped dispatch in AppProvider. */ + recordDispatch(actionType: unknown, durationMs: number): void { + if (!enabled()) return; + record(dispatchBuckets, actionType == null ? '' : String(actionType), durationMs, 'dispatch'); + }, + + /** Time the full handler run for a single postMessage. */ + recordMessage(messageType: unknown, durationMs: number): void { + if (!enabled()) return; + record(messageBuckets, `msg:${messageType == null ? '' : String(messageType)}`, durationMs, 'message'); + }, + + /** Time a render+commit cycle of ChatContent. */ + recordRender(durationMs: number): void { + if (!enabled()) return; + renderCount += 1; + renderTotalMs += durationMs; + if (durationMs > renderMaxMs) renderMaxMs = durationMs; + if (durationMs > SLOW_THRESHOLD_MS) renderSlowCount += 1; + if (durationMs > FRAME_BUDGET_MS && typeof console !== 'undefined' && console.warn) { + console.warn( + `%c[OC PERFL FRAME-BLOW] render+commit = ${durationMs.toFixed(1)}ms`, + 'color: #ef4444; font-weight: bold;', + ); + } + ensureFlushScheduled(); + }, + + /** Force a flush (useful from console: `__ocPerfProbeFlush()`). */ + flush: flushReport, +}; + +if (isBrowser) { + (window as unknown as { __ocPerfProbeFlush?: () => void }).__ocPerfProbeFlush = flushReport; +} diff --git a/webview/shared/src/chat/lib/subagents/stateManager.ts b/webview/shared/src/chat/lib/subagents/stateManager.ts index d8b3f7b..29a8fd9 100644 --- a/webview/shared/src/chat/lib/subagents/stateManager.ts +++ b/webview/shared/src/chat/lib/subagents/stateManager.ts @@ -70,7 +70,10 @@ function detailFromSummary(summary: SubagentSummary): SubagentDetail { return { ...summary, ...emptySubagentEventCollections(), - references: [...summary.references], + // Persisted/legacy summaries are external data. A malformed references + // object must not crash transcript hydration when this compatibility view + // is rebuilt. + references: Array.isArray(summary.references) ? [...summary.references] : [], }; } diff --git a/webview/shared/src/chat/lib/types.ts b/webview/shared/src/chat/lib/types.ts index 883d1bc..7135ef1 100644 --- a/webview/shared/src/chat/lib/types.ts +++ b/webview/shared/src/chat/lib/types.ts @@ -192,6 +192,8 @@ export interface PendingUserMessage { clientRequestId?: string; images?: string[]; attachments?: AttachmentItem[]; + /** File/resource contexts selected through @ mentions before SDK hydration. */ + contexts?: ContextItem[]; interactiveSubmit?: boolean; confirmedMessageId?: string; confirmedAt?: number; @@ -231,6 +233,9 @@ export interface ReasoningEvent { createdAt: number; partID?: string; messageID?: string; + /** Unix-ms timestamps supplied by the SDK reasoning part. */ + startedAt?: number; + endedAt?: number; delta?: boolean; } @@ -318,6 +323,8 @@ export interface StreamingState { export interface MessageInfo { id?: string; + /** Parent user-message ID for an SDK assistant envelope. */ + parentID?: string; agent?: string; role?: string; model?: { modelID: string; providerID: string }; @@ -491,6 +498,12 @@ export interface MessageStep { callID?: string; /** Step identity key, mirrors StreamingStep.id */ id?: string; + /** SDK message/session ownership retained for hydrated tool parts. */ + messageID?: string; + sessionID?: string; + /** SDK tool execution timestamps in Unix milliseconds. */ + startedAt?: number; + endedAt?: number; /** Arrival-order sequence number, mirrors StreamingStep.streamSeq — used to replay interleaved timeline on reload */ streamSeq?: number; /** File path associated with this step, used for deduplication */ @@ -838,7 +851,12 @@ export interface AppState { currentSessionId: string | null; messages: Message[]; messagesBySessionId?: Record; - rawSdkEventPayloadsBySessionId?: Record; + /** + * LOCKED CONTRACT — copied only from the current OpenCode SDK server + * `client.session.messages()` response. This is an in-memory debug mirror, + * never persisted and never a source/fallback for rehydration. + */ + sdkMessagesBySessionId?: Record; /** Unfiltered live stream mirror for debugging; intentionally never persisted. */ liveEventStreamBySessionId?: Record; liveToastNotificationsBySessionId?: Record; diff --git a/webview/shared/src/components/MarkdownRenderer.tsx b/webview/shared/src/components/MarkdownRenderer.tsx index baff5ae..b4da669 100644 --- a/webview/shared/src/components/MarkdownRenderer.tsx +++ b/webview/shared/src/components/MarkdownRenderer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, forwardRef, memo } from 'react'; +import React, { useEffect, useMemo, useRef, useState, forwardRef, memo } from 'react'; import { marked } from 'marked'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; // Default base, we'll custom style it further @@ -18,6 +18,53 @@ interface MarkdownRendererProps { className?: string; isPreParsed?: boolean; isInline?: boolean; + /** + * When true, the component is rendering live streaming text that updates + * many times per second. The markdown parse + syntax highlight pipeline + * is debounced to avoid re-parsing the entire accumulated response on + * every stream batch (which causes main-thread freezes during streaming). + * + * When false (default), behavior is unchanged: content is parsed + * immediately on every change. This preserves the existing UX for + * historical messages, modals, and finalized responses. + */ + isStreaming?: boolean; +} + +/** + * Streaming-aware content debounce. + * + * During active streaming (`isStreaming === true`), the returned value lags + * behind `content` by ~200ms. This collapses the ~20 host-batched updates + * per second down to ~5 markdown re-parses per second — enough for smooth + * reading while preventing `marked.parse()` + `hljs.highlightElement` from + * saturating the main thread. + * + * When `isStreaming === false`, the hook returns `content` directly with + * zero overhead (no timers, no state churn). This preserves the immediate + * render behavior for non-streaming callers (historical messages, modals, + * finalized responses). + * + * When streaming ends (`isStreaming` flips false), the latest `content` is + * returned immediately on the next render so the final response paints + * without the trailing 200ms delay. + */ +function useStreamingDebouncedContent(content: string, isStreaming: boolean): string { + const [debounced, setDebounced] = useState(content); + + useEffect(() => { + if (!isStreaming) { + setDebounced((prev) => (prev === content ? prev : content)); + return; + } + + const timer = window.setTimeout(() => { + setDebounced(content); + }, 200); + return () => window.clearTimeout(timer); + }, [content, isStreaming]); + + return isStreaming ? debounced : content; } const EXT_COLORS: Record = { @@ -393,6 +440,7 @@ const MarkdownRendererInner = forwardRef( className = '', isPreParsed = false, isInline = false, + isStreaming = false, }, ref) => { const innerRef = useRef(null); @@ -406,26 +454,28 @@ const MarkdownRendererInner = forwardRef( } }; + const streamingContent = useStreamingDebouncedContent(content, isStreaming); + // Pre-process markdown to fix numbered lists with blank lines // Also brutally truncate massive strings to prevent the UI thread from freezing/crashing // during `marked.parse` when a tool command outputs megabytes of text. const processedContent = useMemo(() => { - let raw = content || ''; + let raw = streamingContent || ''; if (raw.length > 150000) { raw = raw.slice(0, 150000) + `\n\n...[content abruptly truncated from ${raw.length} to 150000 characters for performance]`; } return isPreParsed ? raw : preprocessMarkdown(raw); - }, [content, isPreParsed]); + }, [streamingContent, isPreParsed]); const html = useMemo( () => ( isPreParsed - ? content + ? streamingContent : isInline ? marked.parseInline(processedContent) : marked.parse(processedContent) ), - [content, isInline, isPreParsed, processedContent], + [streamingContent, isInline, isPreParsed, processedContent], ); useEffect(() => { diff --git a/webview/shared/src/config.ts b/webview/shared/src/config.ts index a0d9846..daef6a8 100644 --- a/webview/shared/src/config.ts +++ b/webview/shared/src/config.ts @@ -13,9 +13,8 @@ export const config = { * @default true */ showBrowserConsole: false, - // This panel serializes the full raw/live event tapes. Keep it opt-in so - // its diagnostic rendering never competes with the streaming UI. - showCentralizedDebug: false, + /** Show copyable raw SDK hydration and live-event payloads. */ + showSdkEventDebug: false, }, };