Skip to content

Commit b2bf399

Browse files
committed
fix(client): surface desktop AI stream request failures
1 parent cf39305 commit b2bf399

4 files changed

Lines changed: 297 additions & 5 deletions

File tree

‎chat2db-community-client/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"test:search-result-tab-selection": "tsx src/blocks/SearchResult/tabSelection.test.ts",
6464
"test:saved-console-tree-refresh": "tsx src/store/tree/savedConsoleTreeRefresh.test.ts",
6565
"test:saved-console-lifecycle": "tsx src/store/workspace/utils/savedConsoleLifecycle.test.ts && tsx src/store/workspace/utils/workspaceTabPersistence.test.ts",
66-
"test:sse-request": "tsx src/components/SSERequest/requestOwnership.test.ts && tsx src/components/SSERequest/requestTransport.test.ts",
66+
"test:sse-request": "tsx src/components/SSERequest/requestOwnership.test.ts && tsx src/components/SSERequest/requestTransport.test.ts && tsx src/service/sse/requestFailure.test.ts",
6767
"test:settings-layout": "tsx src/blocks/Setting/navigation.test.ts && tsx src/blocks/Setting/search.test.ts && tsx src/blocks/Setting/BaseSetting/model.test.ts && tsx src/client-extension/settingMenus.test.ts && tsx src/blocks/Setting/settingsLayout.test.ts",
6868
"test:shortcut": "tsx src/constants/shortcut.test.ts && tsx src/utils/appTitleBarAction.test.ts && tsx src/layouts/GlobalLayout/AppTitleBar/platform.test.ts && tsx src/utils/jcefZoom.test.ts && tsx src/utils/shortcutDispatch.test.ts",
6969
"test:sql-execution-log": "tsx src/service/sqlExecutionLog.test.ts",

‎chat2db-community-client/src/service/sse/index.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useGlobalStore } from '@/store/global';
2+
import { JcefEventBus } from '@/jcef/eventBus';
23
import { v4 as uuidv4 } from 'uuid';
4+
import { dispatchDesktopStreamRequest } from './requestFailure';
35

46
export interface IJcefSseRequest {
57
requestId: string;
@@ -35,10 +37,11 @@ const sendClientSSERequest = (baseURL, message) => {
3537
}),
3638
);
3739

38-
window.javaQuery({
39-
request: JSON.stringify(res),
40-
onSuccess: function () {},
41-
onFailure: function () {},
40+
dispatchDesktopStreamRequest({
41+
requestId,
42+
serializedRequest: JSON.stringify(res),
43+
javaQuery: typeof window.javaQuery === 'function' ? (request) => window.javaQuery(request) : undefined,
44+
publish: (eventName, output) => JcefEventBus.publish(eventName, output),
4245
});
4346

4447
return {
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import assert from 'node:assert/strict';
2+
import { JcefEventBus } from '@/jcef/eventBus';
3+
import { createClientRequestHandle } from '@/components/SSERequest/clientRequestHandle';
4+
import type { SSERequestCallbacks } from '@/components/SSERequest';
5+
import {
6+
buildStreamErrorOutput,
7+
dispatchDesktopStreamRequest,
8+
describeRequestFailure,
9+
extractEnvelopeErrorMessage,
10+
streamEventName,
11+
} from './requestFailure';
12+
13+
const testExtractEnvelopeErrorMessage = () => {
14+
const errorEnvelope = JSON.stringify({
15+
actionType: 'error',
16+
uuid: 'req-1',
17+
message: { success: false, errorCode: 'model.invalid', errorMessage: 'AI model is not available' },
18+
});
19+
assert.equal(extractEnvelopeErrorMessage(errorEnvelope), 'AI model is not available');
20+
21+
assert.equal(
22+
extractEnvelopeErrorMessage({ actionType: 'error', message: { success: false } }),
23+
'AI stream request failed',
24+
);
25+
26+
const failedActionResultOnly = { actionType: 'execute', message: { success: false, errorMessage: 'boom' } };
27+
assert.equal(extractEnvelopeErrorMessage(failedActionResultOnly), 'boom');
28+
29+
assert.equal(extractEnvelopeErrorMessage(JSON.stringify({ actionType: 'execute', uuid: 'req-1' })), undefined);
30+
assert.equal(extractEnvelopeErrorMessage({ actionType: 'execute', message: { success: true } }), undefined);
31+
assert.equal(extractEnvelopeErrorMessage('not json'), undefined);
32+
assert.equal(extractEnvelopeErrorMessage(undefined), undefined);
33+
assert.equal(extractEnvelopeErrorMessage(42), undefined);
34+
};
35+
36+
const testDescribeRequestFailure = () => {
37+
assert.equal(describeRequestFailure(500, 'bridge unavailable'), 'bridge unavailable');
38+
assert.equal(describeRequestFailure(500, ' '), 'AI stream request failed (500)');
39+
assert.equal(describeRequestFailure(undefined, undefined), 'AI stream request failed');
40+
};
41+
42+
const testPublishedFailureSettlesStreamHandle = async () => {
43+
const eventName = streamEventName('failed-before-stream');
44+
const errors: Error[] = [];
45+
const successes: unknown[][] = [];
46+
const callbacks: SSERequestCallbacks<any> = {
47+
onSuccess: (chunks) => successes.push(chunks),
48+
onError: (error) => errors.push(error),
49+
onUpdate: () => {},
50+
};
51+
const handle = createClientRequestHandle({
52+
callbacks,
53+
eventBus: JcefEventBus,
54+
eventName,
55+
handleErrorPayload: () => false,
56+
});
57+
58+
JcefEventBus.publish(eventName, buildStreamErrorOutput('AI model is not available'));
59+
60+
await assert.rejects(handle.done, /AI model is not available/);
61+
assert.equal(errors.length, 1);
62+
assert.equal(successes.length, 0);
63+
64+
// The listener must be removed once settled: a late event changes nothing.
65+
JcefEventBus.publish(eventName, buildStreamErrorOutput('late'));
66+
assert.equal(errors.length, 1);
67+
};
68+
69+
const createFailureHandle = (requestId: string) => {
70+
const errors: Error[] = [];
71+
const updates: unknown[] = [];
72+
const handle = createClientRequestHandle({
73+
callbacks: {
74+
onSuccess: () => assert.fail('failed stream must not succeed'),
75+
onError: (error) => errors.push(error),
76+
onUpdate: (output) => updates.push(output),
77+
},
78+
eventBus: JcefEventBus,
79+
eventName: streamEventName(requestId),
80+
handleErrorPayload: () => false,
81+
});
82+
return { handle, errors, updates };
83+
};
84+
85+
const testSynchronousErrorEnvelopeWaitsForListener = async () => {
86+
const requestId = 'sync-error-envelope';
87+
dispatchDesktopStreamRequest({
88+
requestId,
89+
serializedRequest: '{}',
90+
javaQuery: ({ onSuccess }) => {
91+
onSuccess(
92+
JSON.stringify({
93+
actionType: 'error',
94+
message: { success: false, errorMessage: 'model unavailable' },
95+
}),
96+
);
97+
},
98+
publish: JcefEventBus.publish,
99+
});
100+
const { handle, errors, updates } = createFailureHandle(requestId);
101+
102+
await assert.rejects(handle.done, /model unavailable/);
103+
assert.equal(errors.length, 1);
104+
assert.equal(updates.length, 1);
105+
};
106+
107+
const testSynchronousNativeFailureWaitsForListener = async () => {
108+
const requestId = 'sync-native-failure';
109+
dispatchDesktopStreamRequest({
110+
requestId,
111+
serializedRequest: '{}',
112+
javaQuery: ({ onFailure }) => onFailure(503, 'bridge unavailable'),
113+
publish: JcefEventBus.publish,
114+
});
115+
const { handle, errors } = createFailureHandle(requestId);
116+
117+
await assert.rejects(handle.done, /bridge unavailable/);
118+
assert.equal(errors.length, 1);
119+
JcefEventBus.publish(streamEventName(requestId), buildStreamErrorOutput('late'));
120+
assert.equal(errors.length, 1);
121+
};
122+
123+
const testMissingAndThrowingBridgePublishFailures = async () => {
124+
const published: Array<{ eventName: string; output: ReturnType<typeof buildStreamErrorOutput> }> = [];
125+
const publish = (eventName: string, output: ReturnType<typeof buildStreamErrorOutput>) => {
126+
published.push({ eventName, output });
127+
};
128+
129+
dispatchDesktopStreamRequest({ requestId: 'missing', serializedRequest: '{}', publish });
130+
dispatchDesktopStreamRequest({
131+
requestId: 'throwing',
132+
serializedRequest: '{}',
133+
javaQuery: () => {
134+
throw new Error('bridge threw');
135+
},
136+
publish,
137+
});
138+
await Promise.resolve();
139+
140+
assert.deepEqual(
141+
published.map(({ eventName }) => eventName),
142+
[streamEventName('missing'), streamEventName('throwing')],
143+
);
144+
assert.match(published[0].output.data, /Java Query is not available/);
145+
assert.match(published[1].output.data, /bridge threw/);
146+
};
147+
148+
const testNormalAcknowledgementDoesNotPublish = async () => {
149+
const published: unknown[] = [];
150+
dispatchDesktopStreamRequest({
151+
requestId: 'normal-ack',
152+
serializedRequest: '{}',
153+
javaQuery: ({ onSuccess }) => onSuccess(JSON.stringify({ actionType: 'execute', message: { success: true } })),
154+
publish: (...args) => published.push(args),
155+
});
156+
await Promise.resolve();
157+
assert.deepEqual(published, []);
158+
};
159+
160+
const main = async () => {
161+
testExtractEnvelopeErrorMessage();
162+
testDescribeRequestFailure();
163+
await testPublishedFailureSettlesStreamHandle();
164+
await testSynchronousErrorEnvelopeWaitsForListener();
165+
await testSynchronousNativeFailureWaitsForListener();
166+
await testMissingAndThrowingBridgePublishFailures();
167+
await testNormalAcknowledgementDoesNotPublish();
168+
console.log('sse request failure tests passed');
169+
};
170+
171+
main().catch((error) => {
172+
console.error(error);
173+
process.exitCode = 1;
174+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { JavaPushActionType } from '@/jcef/eventBus';
2+
3+
const DEFAULT_FAILURE_MESSAGE = 'AI stream request failed';
4+
5+
export const streamEventName = (requestId: string) => `${JavaPushActionType.AI_SSE_MESSAGE}_${requestId}`;
6+
7+
/**
8+
* Shapes a request-level failure like a stream error event so the stream listener
9+
* settles through its existing error path (onError + rejected done + cleanup)
10+
* instead of waiting forever for stream events that will never arrive.
11+
*/
12+
export const buildStreamErrorOutput = (message: string) => ({
13+
event: 'error',
14+
data: JSON.stringify({ content: message }),
15+
});
16+
17+
export const describeRequestFailure = (errorCode: unknown, errorMessage: unknown): string => {
18+
if (typeof errorMessage === 'string' && errorMessage.trim()) {
19+
return errorMessage;
20+
}
21+
if (errorCode === undefined || errorCode === null || `${errorCode}`.trim() === '') {
22+
return DEFAULT_FAILURE_MESSAGE;
23+
}
24+
return `${DEFAULT_FAILURE_MESSAGE} (${errorCode})`;
25+
};
26+
27+
interface JavaQueryRequest {
28+
request: string;
29+
onSuccess: (data: unknown) => void;
30+
onFailure: (errorCode: unknown, errorMessage: unknown) => void;
31+
}
32+
33+
interface DispatchDesktopStreamRequestOptions {
34+
requestId: string;
35+
serializedRequest: string;
36+
javaQuery?: (request: JavaQueryRequest) => unknown;
37+
publish: (eventName: string, output: ReturnType<typeof buildStreamErrorOutput>) => void;
38+
schedule?: (callback: () => void) => void;
39+
}
40+
41+
const thrownFailureMessage = (error: unknown) => {
42+
if (error instanceof Error && error.message.trim()) {
43+
return error.message;
44+
}
45+
return describeRequestFailure(undefined, error);
46+
};
47+
48+
export const dispatchDesktopStreamRequest = ({
49+
requestId,
50+
serializedRequest,
51+
javaQuery,
52+
publish,
53+
schedule = queueMicrotask,
54+
}: DispatchDesktopStreamRequestOptions) => {
55+
const publishFailure = (message: string) => {
56+
schedule(() => publish(streamEventName(requestId), buildStreamErrorOutput(message)));
57+
};
58+
59+
if (!javaQuery) {
60+
publishFailure('Java Query is not available');
61+
return;
62+
}
63+
64+
try {
65+
javaQuery({
66+
request: serializedRequest,
67+
onSuccess: (data) => {
68+
const errorMessage = extractEnvelopeErrorMessage(data);
69+
if (errorMessage !== undefined) {
70+
publishFailure(errorMessage);
71+
}
72+
},
73+
onFailure: (errorCode, errorMessage) => {
74+
publishFailure(describeRequestFailure(errorCode, errorMessage));
75+
},
76+
});
77+
} catch (error) {
78+
publishFailure(thrownFailureMessage(error));
79+
}
80+
};
81+
82+
const parseJsonObject = (value: unknown): Record<string, any> | undefined => {
83+
if (typeof value === 'object' && value !== null) {
84+
return value as Record<string, any>;
85+
}
86+
if (typeof value !== 'string') {
87+
return undefined;
88+
}
89+
try {
90+
const parsed = JSON.parse(value);
91+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
92+
} catch {
93+
return undefined;
94+
}
95+
};
96+
97+
/**
98+
* Detects the ConsoleResult error envelope that the JCEF bridge returns through the
99+
* success callback when the controller fails before the stream is subscribed
100+
* (ConsoleHelper.error: actionType "error" + ActionResult {success: false}).
101+
* Returns undefined for regular acknowledgements.
102+
*/
103+
export const extractEnvelopeErrorMessage = (rawResponse: unknown): string | undefined => {
104+
const envelope = parseJsonObject(rawResponse);
105+
if (!envelope) {
106+
return undefined;
107+
}
108+
const actionResult = parseJsonObject(envelope.message);
109+
const failed = envelope.actionType === 'error' || actionResult?.success === false;
110+
if (!failed) {
111+
return undefined;
112+
}
113+
const errorMessage = actionResult?.errorMessage;
114+
return typeof errorMessage === 'string' && errorMessage.trim() ? errorMessage : DEFAULT_FAILURE_MESSAGE;
115+
};

0 commit comments

Comments
 (0)