From 472ec6497f2fa426bafd26566f19fb484689cabd Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 22 Sep 2026 21:24:20 +0800 Subject: [PATCH] fix(web): plan-card catch, removeTurn on finish, pane ErrorBoundary, CRLF SSE frames T20-F2/F3/F5/F6: reset stuck executing cards, reclaim AbortControllers via removeTurn, wrap chat/doc panes, and split SSE on CRLF blank lines as well as LF. --- web/src/api/chatStream.test.ts | 21 ++++ web/src/api/chatStream.ts | 49 ++++++--- web/src/components/layout/AppShell.tsx | 5 +- .../components/layout/WorkbenchView.test.ts | 2 + web/src/hooks/useCardActions.test.ts | 2 + web/src/hooks/useCardActions.ts | 100 ++++++++++++------ web/src/hooks/useTaskRunAttempts.test.ts | 1 + web/src/hooks/useTaskRunAttempts.ts | 2 + 8 files changed, 133 insertions(+), 49 deletions(-) diff --git a/web/src/api/chatStream.test.ts b/web/src/api/chatStream.test.ts index 5d1e2fd5..927e68fb 100644 --- a/web/src/api/chatStream.test.ts +++ b/web/src/api/chatStream.test.ts @@ -43,3 +43,24 @@ test('parseSSEChunk: skips a single malformed frame and keeps parsing the next v assert.equal(events[0].delta, 'ok'); assert.equal(lastSeq, 2); }); + +test('parseSSEChunk: parses CRLF-delimited frames', () => { + const raw = + 'event: phase\r\ndata: {"type":"phase","phase":{"phase_id":"intent_recognizing"}}\r\n\r\n' + + 'event: delta\r\ndata: {"type":"delta","delta":"hi"}\r\n\r\n'; + const { events, rest } = parseSSEChunk(raw); + assert.equal(rest, ''); + assert.equal(events.length, 2); + assert.equal(events[0].type, 'phase'); + assert.equal(events[1].delta, 'hi'); +}); + +test('parseSSEChunk: mixed LF and CRLF delimiters', () => { + const raw = + 'data: {"type":"status"}\n\n' + + 'data: {"type":"delta","delta":"x"}\r\n\r\n'; + const { events, rest } = parseSSEChunk(raw); + assert.equal(rest, ''); + assert.equal(events.length, 2); + assert.equal(events[1].delta, 'x'); +}); diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 255eade0..4a48472e 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -107,7 +107,7 @@ function parseFrame(frame: string): { event: StreamEvent | null; seq?: number } let dataLine: string | undefined; let seq: number | undefined; - for (const line of frame.split('\n')) { + for (const line of frame.split(/\r?\n/)) { if (line.startsWith('id:')) { const n = Number(line.slice(3).trim()); if (Number.isFinite(n)) seq = n; @@ -127,6 +127,38 @@ function parseFrame(frame: string): { event: StreamEvent | null; seq?: number } } } +/** Split complete SSE frames; accept both LF and CRLF blank-line delimiters. */ +function takeSSEFrames( + buffer: string, + flush?: boolean, +): { frames: string[]; rest: string } { + const frames: string[] = []; + let start = 0; + while (start < buffer.length) { + const lfIdx = buffer.indexOf('\n\n', start); + const crlfIdx = buffer.indexOf('\r\n\r\n', start); + let delimAt = -1; + let delimLen = 0; + if (lfIdx === -1 && crlfIdx === -1) { + break; + } + if (crlfIdx !== -1 && (lfIdx === -1 || crlfIdx <= lfIdx)) { + delimAt = crlfIdx; + delimLen = 4; + } else { + delimAt = lfIdx; + delimLen = 2; + } + frames.push(buffer.slice(start, delimAt)); + start = delimAt + delimLen; + } + if (flush && start < buffer.length) { + frames.push(buffer.slice(start)); + return { frames, rest: '' }; + } + return { frames, rest: buffer.slice(start) }; +} + export function parseSSEChunk( buffer: string, options?: { flush?: boolean }, @@ -134,22 +166,13 @@ export function parseSSEChunk( const events: StreamEvent[] = []; if (!buffer) return { events, rest: '' }; - const frames = buffer.split('\n\n'); - const hasTrailingDelimiter = buffer.endsWith('\n\n'); - let processCount = hasTrailingDelimiter ? frames.length : Math.max(0, frames.length - 1); - - if (options?.flush && !hasTrailingDelimiter && frames.length > 0) { - processCount = frames.length; - } - + const { frames, rest } = takeSSEFrames(buffer, options?.flush); let lastSeq: number | undefined; - for (let i = 0; i < processCount; i++) { - const parsed = parseFrame(frames[i]); + for (const frame of frames) { + const parsed = parseFrame(frame); if (parsed.seq !== undefined) lastSeq = parsed.seq; if (parsed.event) events.push(parsed.event); } - - const rest = hasTrailingDelimiter || options?.flush ? '' : (frames[frames.length - 1] ?? ''); return { events, rest, lastSeq }; } diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 49306e75..34a91530 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -4,6 +4,7 @@ import { LeftRail } from './LeftRail'; import { ResizeHandle } from './ResizeHandle'; import { ChatColumn } from './ChatColumn'; import { RightDocRail } from './RightDocRail'; +import { ErrorBoundary } from '../ErrorBoundary'; import { ProjectSession } from '../../types/workbench'; import { UserRole, UserProfile } from '../../types/docgov'; import { loadRightRailCollapsed, saveRightRailCollapsed } from '../../data/storage'; @@ -188,7 +189,7 @@ export const AppShell: React.FC = ({ {/* 中间栏 */} {workflowBanner} - {chatContent} + {chatContent} {!rightCollapsed && ( @@ -205,7 +206,7 @@ export const AppShell: React.FC = ({ {/* 右侧栏 */} - {docContent} + {docContent} diff --git a/web/src/components/layout/WorkbenchView.test.ts b/web/src/components/layout/WorkbenchView.test.ts index 745325e9..24651b62 100644 --- a/web/src/components/layout/WorkbenchView.test.ts +++ b/web/src/components/layout/WorkbenchView.test.ts @@ -5,9 +5,11 @@ import test from 'node:test'; test('workbench shell lives outside App', () => { const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'); const view = readFileSync(new URL('./WorkbenchView.tsx', import.meta.url), 'utf8'); + const shell = readFileSync(new URL('./AppShell.tsx', import.meta.url), 'utf8'); assert.equal((app.match(//g) || []).length >= 2, true); }); diff --git a/web/src/hooks/useCardActions.test.ts b/web/src/hooks/useCardActions.test.ts index d4abdf03..87714187 100644 --- a/web/src/hooks/useCardActions.test.ts +++ b/web/src/hooks/useCardActions.test.ts @@ -9,6 +9,8 @@ test('interactive card actions live in one hook', () => { assert.equal(app.includes('submitIntentPlanDecision'), false); assert.equal(hook.includes('isPlanConfirmCard'), true); assert.equal(hook.includes('httpStatus === 409'), true); + assert.equal(hook.includes('try {'), true); + assert.equal(hook.includes('} catch {'), true); assert.equal(hook.includes("actionId === 'confirm_reject'"), true); assert.equal(hook.includes("actionId === 'undo'"), true); }); diff --git a/web/src/hooks/useCardActions.ts b/web/src/hooks/useCardActions.ts index ae61d44b..5624e730 100644 --- a/web/src/hooks/useCardActions.ts +++ b/web/src/hooks/useCardActions.ts @@ -112,42 +112,74 @@ export function useCardActions(input: CardActionsInput) { ) ); void (async () => { - const decisionNonce = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${cardId}:${actionId}:${expectedRevision}:${Date.now()}:${Math.random()}`; - const result = await submitIntentPlanDecision({ - planId, - projectId, - sessionId, - expectedRevision, - decisionNonce, - decisionDigest: `${kind}:${expectedRevision}`, - kind, - }); - setMessages((prev) => - prev.map((m) => { - if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m; - if (result.httpStatus === 409) { - return { ...m, interactiveCard: reconcileConflictCard(m.interactiveCard, result.plan) }; + try { + const decisionNonce = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${cardId}:${actionId}:${expectedRevision}:${Date.now()}:${Math.random()}`; + const result = await submitIntentPlanDecision({ + planId, + projectId, + sessionId, + expectedRevision, + decisionNonce, + decisionDigest: `${kind}:${expectedRevision}`, + kind, + }); + setMessages((prev) => + prev.map((m) => { + if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m; + if (result.httpStatus === 409) { + return { ...m, interactiveCard: reconcileConflictCard(m.interactiveCard, result.plan) }; + } + if (result.plan) { + return { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, result.plan) }; + } + return m; + }) + ); + if (!result.plan && result.httpStatus >= 400) { + const latest = await fetchIntentPlan(planId, projectId, sessionId).catch(() => null); + if (latest) { + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId && m.interactiveCard + ? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, latest) } + : m + ) + ); + } else { + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId && m.interactiveCard + ? { + ...m, + interactiveCard: markPlanCard( + m.interactiveCard, + 'active', + '计划确认失败,请重试' + ), + } + : m + ) + ); } - if (result.plan) { - return { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, result.plan) }; - } - return m; - }) - ); - if (!result.plan && result.httpStatus >= 400) { - const latest = await fetchIntentPlan(planId, projectId, sessionId).catch(() => null); - if (latest) { - setMessages((prev) => - prev.map((m) => - m.interactiveCard?.cardId === cardId && m.interactiveCard - ? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, latest) } - : m - ) - ); } + } catch { + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId && m.interactiveCard + ? { + ...m, + interactiveCard: markPlanCard( + m.interactiveCard, + 'active', + '计划确认失败,请重试' + ), + } + : m + ) + ); } })(); return; diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index d4fdf54e..3d0f1068 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -18,6 +18,7 @@ test('chat and task attempts live in one hook', () => { assert.equal(hook.includes('streamChat'), true); assert.equal(hook.includes('createTaskRun'), true); assert.equal(hook.includes('shouldSubscribeExistingRun'), true); + assert.equal(hook.includes('removeTurn'), true); assert.equal(hook.includes('禁止无条件 setRunId(null)'), true); assert.equal(hook.includes('resumeTaskRun'), true); assert.equal(hook.includes('chatWindow.allMessages.find'), true); diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index ffae04b2..7e4d912d 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -170,7 +170,9 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => { if (opts?.clientTurnId) { + // Terminal status previously left AbortController in the turn map forever. turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status); + turnRuntime.removeTurn(streamSessionId, opts.clientTurnId); } };