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.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
+36
-13
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppShellProps> = ({
|
||||
{/* 中间栏 */}
|
||||
<ChatColumn currentSession={currentSession}>
|
||||
{workflowBanner}
|
||||
{chatContent}
|
||||
<ErrorBoundary>{chatContent}</ErrorBoundary>
|
||||
</ChatColumn>
|
||||
|
||||
{!rightCollapsed && (
|
||||
@@ -205,7 +206,7 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<RightDocRail width={rightWidth} collapsed={rightCollapsed}>
|
||||
{docContent}
|
||||
<ErrorBoundary>{docContent}</ErrorBoundary>
|
||||
</RightDocRail>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(/<WorkbenchView\b/g) || []).length, 1);
|
||||
assert.equal(app.includes('<AppShell'), false);
|
||||
assert.equal(view.includes('<AppShell'), true);
|
||||
assert.equal(view.includes('<ProjectSettingsModal'), true);
|
||||
assert.equal(view.includes('<FirstLoginPasswordModal'), true);
|
||||
assert.equal((shell.match(/<ErrorBoundary>/g) || []).length >= 2, true);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user