Skip to content
Merged
3 changes: 3 additions & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export const IPC_CHANNELS = {
// Window channels
WINDOW_SET_TITLE: "window:setTitle",

// Debug channels (for testing only)
DEBUG_TRIGGER_STREAM_ERROR: "debug:triggerStreamError",

// Dynamic channel prefixes
WORKSPACE_CHAT_PREFIX: "workspace:chat:",
WORKSPACE_METADATA: "workspace:metadata",
Expand Down
19 changes: 19 additions & 0 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,25 @@ export class IpcMain {
log.error(`Failed to open terminal: ${message}`);
}
});

// Debug IPC - only for testing
ipcMain.handle(
IPC_CHANNELS.DEBUG_TRIGGER_STREAM_ERROR,
(_event, workspaceId: string, errorMessage: string) => {
try {
// eslint-disable-next-line @typescript-eslint/dot-notation -- accessing private member for testing
const triggered = this.aiService["streamManager"].debugTriggerStreamError(
workspaceId,
errorMessage
);
return { success: triggered };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`Failed to trigger stream error: ${message}`);
return { success: false, error: message };
}
}
);
}

/**
Expand Down
58 changes: 58 additions & 0 deletions src/services/streamManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1271,4 +1271,62 @@ export class StreamManager extends EventEmitter {
this.emitPartAsEvent(typedWorkspaceId, streamInfo.messageId, part);
}
}

/**
* DEBUG ONLY: Trigger an artificial stream error for testing
* This method allows integration tests to simulate stream errors without
* mocking the AI SDK or network layer. It triggers the same error handling
* path as genuine stream errors by aborting the stream and manually triggering
* the error event (since abort alone doesn't throw, it just sets a flag).
*/
debugTriggerStreamError(workspaceId: string, errorMessage: string): boolean {
const typedWorkspaceId = workspaceId as WorkspaceId;
const streamInfo = this.workspaceStreams.get(typedWorkspaceId);

// Only trigger error if stream is actively running
if (
!streamInfo ||
(streamInfo.state !== StreamState.STARTING && streamInfo.state !== StreamState.STREAMING)
) {
return false;
}

// Abort the stream first
streamInfo.abortController.abort(new Error(errorMessage));

// Update streamInfo metadata with error (so subsequent flushes preserve it)
streamInfo.initialMetadata = {
...streamInfo.initialMetadata,
error: errorMessage,
errorType: "network",
};

// Write error state to partial.json (same as real error handling)
const errorPartialMessage: CmuxMessage = {
id: streamInfo.messageId,
role: "assistant",
metadata: {
historySequence: streamInfo.historySequence,
timestamp: streamInfo.startTime,
model: streamInfo.model,
partial: true,
error: errorMessage,
errorType: "network", // Test errors are network-like
...streamInfo.initialMetadata,
},
parts: streamInfo.parts,
};
void this.partialService.writePartial(workspaceId, errorPartialMessage);

// Emit error event (same as real error handling)
this.emit("error", {
type: "error",
workspaceId,
messageId: streamInfo.messageId,
error: errorMessage,
errorType: "network",
} as ErrorEvent);

return true;
}
}
Loading