fix(chat): recover active requests and preserve finish order

This commit is contained in:
john
2026-07-16 21:04:32 +08:00
parent 933466c8ab
commit c1012da120
4 changed files with 65 additions and 3 deletions
+9 -2
View File
@@ -39,8 +39,15 @@ export function shouldPromoteSessionIdToStreaming(chatState) {
* @param {number | undefined | null} status
* @returns {boolean}
*/
export function shouldKeepStreamingAfterRunError(status) {
return status === 0 || status === 409 || Number(status) >= 500;
export function shouldKeepStreamingAfterRunError(status, message = '', code = '') {
if (status === 0 || status === 409 || Number(status) >= 500) return true;
// Goose can surface the session-level concurrency guard as a failed agent
// run (rather than an HTTP 409). The request may already be streaming and
// the session SSE is still the source of truth, so recover from the session
// instead of showing a terminal error in the chat composer.
const text = `${String(code ?? '')} ${String(message ?? '')}`.toLowerCase();
return text.includes('session already has an active request')
|| text.includes('active request. cancel it first');
}
/**
+8
View File
@@ -54,6 +54,14 @@ test('only ambiguous transport failures keep the chat streaming', () => {
assert.equal(shouldKeepStreamingAfterRunError(503), true);
assert.equal(shouldKeepStreamingAfterRunError(400), false);
assert.equal(shouldKeepStreamingAfterRunError(undefined), false);
assert.equal(
shouldKeepStreamingAfterRunError(
undefined,
'Session already has an active request. Cancel it first.',
),
true,
);
assert.equal(shouldKeepStreamingAfterRunError(undefined, '后台任务失败'), false);
});
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
+30
View File
@@ -80,3 +80,33 @@ test('mergeConversationSnapshot keeps local messages before the first and after
['u0', 'u1', 'a1', 'a2'],
);
});
test('mergeConversationSnapshot keeps local-only messages at their streamed position', () => {
const local = [
msg('u1', 'user', '生成页面'),
msg('a1', 'assistant', '开始分析'),
msg('a2', 'assistant', '调用工具'),
msg('a3', 'assistant', '页面完成'),
];
const server = [
msg('u1', 'user', '生成页面'),
msg('a3', 'assistant', '页面完成(服务端)'),
];
const merged = mergeConversationSnapshot(local, server);
assert.deepEqual(merged.map((message) => message.id), ['u1', 'a1', 'a2', 'a3']);
assert.equal(merged[3].content[0].text, '页面完成(服务端)');
});
test('mergeConversationSnapshot preserves multiple local messages before the next server anchor', () => {
const local = [
msg('u1', 'user', '任务'),
msg('a1', 'assistant', '步骤一'),
msg('a2', 'assistant', '步骤二'),
msg('a3', 'assistant', '完成'),
];
const server = [msg('u1', 'user', '任务'), msg('a3', 'assistant', '完成')];
assert.deepEqual(
mergeConversationSnapshot(local, server).map((message) => message.id),
['u1', 'a1', 'a2', 'a3'],
);
});
+18 -1
View File
@@ -1618,7 +1618,7 @@ export function useTKMindChat(
if (
activeSessionId &&
err instanceof ApiError &&
shouldKeepStreamingAfterRunError(err.status)
shouldKeepStreamingAfterRunError(err.status, err.message, err.code)
) {
subscribeToSession(activeSessionId);
setChatState('streaming');
@@ -1626,6 +1626,23 @@ export function useTKMindChat(
scheduleReplyRecoverySync(activeSessionId, submitToken);
return;
}
if (
activeSessionId &&
shouldKeepStreamingAfterRunError(
undefined,
err instanceof Error ? err.message : String(err),
err instanceof ApiError ? err.code : '',
)
) {
// Goose may report its session concurrency guard as a failed run
// message instead of an HTTP 409. Reattach to the session stream
// and reconcile the snapshot; do not strand the composer in error.
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
scheduleReplyRecoverySync(activeSessionId, submitToken);
return;
}
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();