Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions scripts/test-impact-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@
"name": "chat-provider-orchestrator",
"prefixes": [
"src/providers/ChatViewProvider.ts",
"src/providers/chat/SessionHandler.ts",
"src/providers/chat/StreamEventHandler.ts",
"src/providers/chat/StructuredOutputProcessor.ts",
"src/services/MessageStreamService.ts",
"src/services/SubagentTracker.ts",
"src/services/GeminiTokenUsageTracker.ts",
"src/shared/createPlainObjectSnapshot.ts",
"webview/shared/src/chat/lib/messageHandler.ts",
"webview/shared/src/chat/lib/truncateLargeStrings.ts"
],
Expand All @@ -42,7 +47,10 @@
"tests/webview/message-handler-processing.test.mjs",
"tests/webview/interactive-events-contract.test.mjs",
"tests/webview/truncate-large-strings-behavior.test.mjs",
"tests/unit/system-prompt-history-filter.test.mjs"
"tests/unit/system-prompt-history-filter.test.mjs",
"tests/regression/perf-stream-clone-redundancy-regression.test.mjs",
"tests/regression/perf-bounded-stream-state-regression.test.mjs",
"tests/regression/perf-token-emit-debounce-regression.test.mjs"
]
},
{
Expand All @@ -68,7 +76,8 @@
"tests": [
"tests/services/request-budgeter.test.mjs",
"tests/services/quota-service.test.mjs",
"tests/integration/budget-quota-integration-regression.test.mjs"
"tests/integration/budget-quota-integration-regression.test.mjs",
"tests/regression/perf-quota-async-write-regression.test.mjs"
]
},
{
Expand All @@ -84,7 +93,8 @@
"tests/webview/interactive-events-contract.test.mjs",
"tests/webview/truncate-large-strings-behavior.test.mjs",
"tests/webview/toast-events-behavior.test.mjs",
"tests/webview/subagent-duration-behavior.test.mjs"
"tests/webview/subagent-duration-behavior.test.mjs",
"tests/regression/perf-subagent-modal-poll-thrash-regression.test.mjs"
]
},
{
Expand Down
136 changes: 95 additions & 41 deletions src/providers/ChatViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
import * as path from "path";
import * as vscode from "vscode";
import { ErrorBuilder } from "./chat/ErrorBuilder";
import type { DisplayError } from "./chat/types";

Check warning on line 103 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

'DisplayError' is defined but never used
import {
CssGenerator,
FileThemeProcessor,
Expand Down Expand Up @@ -351,6 +351,8 @@
private currentTodoItems: unknown[] = [];
private compatibilityWarningsOverride: CompatibilityResult[] | null = null;
private subagentProjectionWrites = new Map<string, Promise<void>>();
private readonly subagentProjectionDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
private static readonly SUBAGENT_PROJECTION_DEBOUNCE_MS = 500;

private getSubagentProjectionStorageKey(sessionId: string): string {
return `opencode.session.subagentProjection.${sessionId}`;
Expand Down Expand Up @@ -380,6 +382,22 @@
return write;
}

/** Trailing-debounce projection persist; coalesces rapid subagent bursts into one write. */
private scheduleSubagentProjectionPersist(sessionId: string): void {
const existing = this.subagentProjectionDebounceTimers.get(sessionId);
if (existing) {
clearTimeout(existing);
}
const timer = setTimeout(() => {
this.subagentProjectionDebounceTimers.delete(sessionId);
void this.persistSubagentProjection(
sessionId,
this.subagentTracker.getSnapshotPayload(),
);
}, ChatViewProvider.SUBAGENT_PROJECTION_DEBOUNCE_MS);
this.subagentProjectionDebounceTimers.set(sessionId, timer);
}

private async restorePersistedSubagentProjection(sessionId: string): Promise<void> {
const projection = this.context.workspaceState.get<
SubagentUpdatePayload & { sessionId?: string }
Expand Down Expand Up @@ -515,16 +533,40 @@
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<string, unknown>),
};
return centralizedEventPayload;
// Fast path: skip the recursive DFS when no string leaf exceeds the IPC cap.
// Upstream cloneRawEvent already produced an independent copy.
if (!this.hasOversizedStringLeaf(enrichedEvent)) {
return enrichedEvent;
}
// Slow path: clone + truncate oversized string leaves.
return this.cloneAndTruncateStreamPayload(enrichedEvent);
}

/** Returns true if any string leaf exceeds MAX_STREAM_WEBVIEW_TOOL_OUTPUT_CHARS. */
private hasOversizedStringLeaf(node: unknown, depth = 0): boolean {
const limit = ChatViewProvider.MAX_STREAM_WEBVIEW_TOOL_OUTPUT_CHARS;
if (depth > ChatViewProvider.STREAM_PAYLOAD_MAX_CLONE_DEPTH) {
return typeof node === "string" && node.length > limit;
}
if (typeof node === "string") {
return node.length > limit;
}
if (Array.isArray(node)) {
for (const entry of node) {
if (this.hasOversizedStringLeaf(entry, depth + 1)) {
return true;
}
}
return false;
}
if (node && typeof node === "object") {
for (const key of Object.keys(node)) {
if (this.hasOversizedStringLeaf((node as Record<string, unknown>)[key], depth + 1)) {
return true;
}
}
}
return false;
}

private cloneAndTruncateStreamPayload(node: unknown, depth = 0): unknown {
Expand Down Expand Up @@ -783,7 +825,7 @@
let permissions: unknown[] = [];

try {
const questionResponse = await (client as any).question.list();

Check warning on line 828 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
questions = extractList(questionResponse, "questions").filter(belongsToSession);
} catch (questionError) {
this.logger.warn("Failed to list pending SDK questions", {
Expand All @@ -793,7 +835,7 @@
}

try {
const permissionResponse = await (client as any).permission.list();

Check warning on line 838 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
permissions = extractList(permissionResponse, "permissions").filter(belongsToSession);
} catch (permissionError) {
this.logger.warn("Failed to list pending SDK permissions", {
Expand Down Expand Up @@ -920,8 +962,8 @@
private lastSendMessageArgs?: {
text: string;
files?: string[];
contexts?: any[];

Check warning on line 965 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
images?: any[];

Check warning on line 966 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
agent?: string;
};
/** OpenCode v2 accepts structured output only through the typed `format` field. */
Expand Down Expand Up @@ -1007,7 +1049,7 @@
this.fileThemeProcessor.subscribe(this);

// Load persisted model selection
const savedModel = this.context.globalState.get<any>("selectedModel");

Check warning on line 1052 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
if (
savedModel &&
typeof savedModel.providerID === "string" &&
Expand Down Expand Up @@ -1145,7 +1187,7 @@
* Wire callbacks between modules and the shell
*/
private wireModuleCallbacks(): void {
const postMessage = (msg: any) => {

Check warning on line 1190 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
this.view?.webview.postMessage(msg);
};

Expand Down Expand Up @@ -1194,7 +1236,7 @@
clientRequestId?: string;
text?: string;
files?: string[];
contexts?: any[];

Check warning on line 1239 in src/providers/ChatViewProvider.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
images?: any[];
agent?: string;
userFacingText?: string;
Expand Down Expand Up @@ -2082,6 +2124,8 @@
});

this.subagentTracker.resetForSession(sessionId);
this.streamedSubtaskPartsBySessionId.clear();
this.structuredOutputProcessor.clearDiagnostics();

// Step 2: Log diagnostic information for debugging
const planMessages = messages.filter((m: any) => m?.plan);
Expand Down Expand Up @@ -3438,10 +3482,14 @@
});
await this.sendPersistedCompactionViewState(currentSession.id);
this.subagentTracker.resetForSession(currentSession.id);
this.streamedSubtaskPartsBySessionId.clear();
this.structuredOutputProcessor.clearDiagnostics();
await this.restorePersistedSubagentProjection(currentSession.id);
this.sendQueueUpdate(currentSession.id);
} else {
this.subagentTracker.resetForSession(null);
this.streamedSubtaskPartsBySessionId.clear();
this.structuredOutputProcessor.clearDiagnostics();
}

await this.handleGetSessions();
Expand Down Expand Up @@ -3755,6 +3803,8 @@
// Clear in-memory todo cache for the newly created session.
this.clearSessionTodos();
this.subagentTracker.resetForSession(createdSession.id);
this.streamedSubtaskPartsBySessionId.clear();
this.structuredOutputProcessor.clearDiagnostics();
this.sendQueueUpdate(createdSession.id);

// Always use "build" as the default agent for new sessions.
Expand Down Expand Up @@ -4572,10 +4622,7 @@
...subagentUpdate,
});
if (subagentParentSessionId) {
void this.persistSubagentProjection(
subagentParentSessionId,
this.subagentTracker.getSnapshotPayload(),
);
this.scheduleSubagentProjectionPersist(subagentParentSessionId);
}
this.sendProcessingSessionsUpdate();
}
Expand Down Expand Up @@ -6256,11 +6303,6 @@
return isTimeout;
}

private async cleanupTimedOutSession(sessionId: string, errorMessage?: string): Promise<void> {
this.logger.warn("Cleaning up timed out session", { sessionId, errorMessage });
await this.handleStopRequest(sessionId, { skipQueueDrain: true });
}

private getUserFacingSendErrorMessage(errorMessage: string): string {
const normalized = errorMessage.trim().toLowerCase();
if (!normalized) {
Expand Down Expand Up @@ -7340,20 +7382,7 @@
hasPlan: !!next.plan,
planFile: next.plan?.file,
planKeys: next.plan ? Object.keys(next.plan) : [],
fullPlanObject: next.plan ? JSON.stringify(next.plan, null, 2) : 'undefined'
});

// DEBUG: Try to serialize the entire next object to check for circular references
try {
const serialized = JSON.stringify(next);
log.debug('Message serialization successful', {
serializedLength: serialized.length,
hasPlanInSerialized: serialized.includes('"plan"'),
planSubstring: serialized.includes('"file"') ? serialized.substring(serialized.indexOf('"plan"'), serialized.indexOf('"plan"') + 200) : 'NOT FOUND'
});
} catch (e) {
log.debug('Message serialization FAILED', { error: e });
}
} else {
log.debug('Plan NOT set - condition failed', {
hasLongPlanContent,
Expand Down Expand Up @@ -7439,6 +7468,9 @@
});

let drainSessionId: string | undefined;
// A timeout from the prompt HTTP request does not prove that the server-side
// agent stopped. The SSE stream remains authoritative for the turn lifetime.
let preserveProcessingAfterTransportTimeout = false;
const capturePromptDebug = this.shouldVerboseStreamDebug();
let debugSessionId: string | undefined;
let baselineAssistantMarker: AssistantHistoryMarker | undefined;
Expand Down Expand Up @@ -8033,6 +8065,8 @@
// Set as current session and retry
await this.sessionService.switchSession(newSession.id);
this.subagentTracker.resetForSession(newSession.id);
this.streamedSubtaskPartsBySessionId.clear();
this.structuredOutputProcessor.clearDiagnostics();

// Notify UI of the ID change if possible, or just refresh sessions
await this.handleGetSessions();
Expand Down Expand Up @@ -8127,6 +8161,15 @@
].join("\n");
}

if (this.isLikelyInteractiveTransportFailure(errorMessage)) {
preserveProcessingAfterTransportTimeout = true;
this.logger.warn(
"Prompt transport timed out; leaving the server-side agent running and waiting for SSE completion",
{ sessionId: session.id, errorMessage },
);
return;
}

const userFacingErrorMessage =
this.getUserFacingSendErrorMessage(errorMessage);
this.logger.info("ERROR_FLOW: Sending error event to webview", {
Expand All @@ -8143,10 +8186,6 @@
sessionId: session.id,
});

if (this.isLikelyInteractiveTransportFailure(errorMessage)) {
await this.cleanupTimedOutSession(session.id, errorMessage);
}

return;
}

Expand Down Expand Up @@ -8516,6 +8555,15 @@
error,
"Failed to send message",
);
if (this.isLikelyInteractiveTransportFailure(errorMessage) && drainSessionId) {
preserveProcessingAfterTransportTimeout = true;
this.logger.warn(
"Prompt transport timed out; leaving the server-side agent running and waiting for SSE completion",
{ sessionId: drainSessionId, errorMessage },
);
return;
}

const userFacingErrorMessage =
this.getUserFacingSendErrorMessage(errorMessage);
vscode.window.showErrorMessage(`Failed to send message: ${userFacingErrorMessage}`);
Expand All @@ -8531,9 +8579,6 @@
message: userFacingErrorMessage,
});

if (this.isLikelyInteractiveTransportFailure(errorMessage) && drainSessionId) {
await this.cleanupTimedOutSession(drainSessionId, errorMessage);
}
} finally {
const totalDuration = Date.now() - overallStartTime;
log.featureStep(flow, 'message_processing_completed', {
Expand All @@ -8549,7 +8594,7 @@
if (debugSessionId) {
this.promptDebugBySession.delete(debugSessionId);
}
if (drainSessionId) {
if (drainSessionId && !preserveProcessingAfterTransportTimeout) {
const shouldPreserveInteractiveContinuation =
sendMeta?.interactiveSubmit === true &&
this.activeStreamSessionId === drainSessionId &&
Expand Down Expand Up @@ -8581,11 +8626,16 @@
this.sessionsNeedingTitle.delete(drainSessionId);
void this.triggerSessionTitleGeneration(drainSessionId);
}
} else if (drainSessionId) {
this.logger.info(
"Keeping processing state after prompt transport timeout; awaiting stream terminal event",
{ sessionId: drainSessionId },
);
}
this.logger.info("Processing request finished", {
sessionId: drainSessionId,
});
if (drainSessionId) {
if (drainSessionId && !preserveProcessingAfterTransportTimeout) {
void this.handleExecuteQueue(drainSessionId);
}

Expand Down Expand Up @@ -10794,6 +10844,10 @@
this.seenClientRequestIds.clear();
this.executingQueueSessionIds.clear();
this.sessionsNeedingTitle?.clear();
for (const timer of this.subagentProjectionDebounceTimers.values()) {
clearTimeout(timer);
}
this.subagentProjectionDebounceTimers.clear();
this.view = undefined;
}

Expand Down
8 changes: 7 additions & 1 deletion src/providers/chat/HistoryProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,11 +510,17 @@ export class HistoryProcessor {
return "";
})();

// Cache the seen-set per target — without this, burst coalesce is O(target × calls).
const seenByTarget = new WeakMap<any[], Set<string>>();
const appendUnique = (target: any[], incoming: any[]): void => {
if (!Array.isArray(incoming) || incoming.length === 0) {
return;
}
const seen = new Set(target.map((entry) => JSON.stringify(entry)));
let seen = seenByTarget.get(target);
if (!seen) {
seen = new Set(target.map((entry) => JSON.stringify(entry)));
seenByTarget.set(target, seen);
}
for (const item of incoming) {
const key = JSON.stringify(item);
if (!seen.has(key)) {
Expand Down
11 changes: 11 additions & 0 deletions src/providers/chat/StreamEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type PendingStreamEvent = {
const STREAM_WEBVIEW_FLUSH_INTERVAL_MS = 50;
const MAX_STREAM_WEBVIEW_EVENTS_PER_BATCH = 8;
const STREAM_WEBVIEW_BACKLOG_YIELD_MS = 16;
// Hard cap on pending events to engage only under backpressure.
const MAX_PENDING_EVENTS = 500;

export class StreamEventHandler {
private streamStartTime?: number;
Expand Down Expand Up @@ -109,6 +111,15 @@ export class StreamEventHandler {
// becoming a scroll-jank bottleneck.
this.pendingEvents.push({ enrichedEvent, event, sessionId });

if (this.pendingEvents.length > MAX_PENDING_EVENTS) {
const overflow = this.pendingEvents.length - MAX_PENDING_EVENTS;
this.pendingEvents.splice(0, overflow);
this.logger.warn("[STREAM-PERF] pendingEvents cap engaged, shed oldest events", {
shed: overflow,
cap: MAX_PENDING_EVENTS,
});
}

if (this.isTerminalEvent(eventType)) {
this.flushPendingEvents();
} else if (this.flushRafId === null) {
Expand Down
5 changes: 5 additions & 0 deletions src/providers/chat/StructuredOutputProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export class StructuredOutputProcessor {
private planManager: PlanManager,
) { }

clearDiagnostics(): void {
this.structuredValidationFailureCounters.clear();
this.structuredOutputIncompatibleModelKeys.clear();
}

private persistPlan(
content: string,
preferredPath?: string,
Expand Down
Loading
Loading