diff --git a/chat-agent-run-gate.mjs b/chat-agent-run-gate.mjs index 11e8dcf..2024d94 100644 --- a/chat-agent-run-gate.mjs +++ b/chat-agent-run-gate.mjs @@ -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'); } /** diff --git a/chat-agent-run-gate.test.mjs b/chat-agent-run-gate.test.mjs index 013a613..21013e9 100644 --- a/chat-agent-run-gate.test.mjs +++ b/chat-agent-run-gate.test.mjs @@ -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', () => { diff --git a/chat-finish-sync.test.mjs b/chat-finish-sync.test.mjs index 4910a8e..32ab80f 100644 --- a/chat-finish-sync.test.mjs +++ b/chat-finish-sync.test.mjs @@ -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'], + ); +}); diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index aa3a44f..bc9ec0f 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -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();