From 4d87b9a401e9e9b70d80105383f14d2b5b741634 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 22 Sep 2026 21:59:05 +0800 Subject: [PATCH 1/2] fix(web): accept CRLF SSE frame delimiters in chatStream Align residual chatStream parsing with sibling t20-lowrisk so LF/CRLF mixed streams parse reliably before integration, with unit coverage. --- .../short-term/2026-09-22-web-api-client.md | 14 +++--- web/src/api/chatStream.test.ts | 21 ++++++++ web/src/api/chatStream.ts | 49 ++++++++++++++----- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/.doc/ops/short-term/2026-09-22-web-api-client.md b/.doc/ops/short-term/2026-09-22-web-api-client.md index a36b13c3..95f2af79 100644 --- a/.doc/ops/short-term/2026-09-22-web-api-client.md +++ b/.doc/ops/short-term/2026-09-22-web-api-client.md @@ -42,14 +42,15 @@ remote_sync: not_requested 1. [x] 新增 `web/src/api/client.ts`:`API_BASE`、Bearer 合并、`ApiError`(兼容 `ProjectSettingsApiError`)、GET/HEAD 对 502/503/504 与网络错误有限重试;`readApiError` 解析信封。 2. [x] 迁移 `web/src/api/*.ts` 裸 `fetch` → `apiFetch`(含 auth / chat / docgov / project* / task* / workflowAdmin / systemAdmin);SSE 显式 `{ retry: false }`。 3. [x] 单元测试:`client.test.ts`(鉴权头、GET 重试、POST 不重试、信封、`auth:false`);既有 projectMembers / chatSessions / freeformDoc 等仍抛 `ProjectSettingsApiError`。 -4. [x] `cd web && npm ci && npm test && npm run build` 通过。 +4. [x] `cd web && npm test && npm run build` 通过。 +5. [x] 从 sibling `t20-lowrisk` 合入 `chatStream` CRLF SSE 帧解析(保留本分支 `apiFetch` + `{ retry: false }`,降低后续集成冲突)。 ## 验证与证据 | 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | |---|---|---|---|---| | 前端 api client / feature tip | `cd web && npm ci` | 安装成功 | PASS | worktree `web/` | -| 同上 | `npm test` | 全绿 | PASS:tsx 477 + vitest 33 | 无失败;含 `client.test.ts` | +| 同上 | `npm test` | 全绿 | PASS:tsx 479 + vitest 33(含 CRLF SSE) | 无失败;含 `client.test.ts` / `chatStream.test.ts` | | 同上 | `npm run build` | `tsc` + vite build 成功 | PASS | 构建完成;chunk size 警告为既有 | 失败面:`readApiError` / `projectSettingsErrorFromResponse` 仍抛带 `status`/`code`/`message` 的错误;域模块继续 `throw`,不吞 rejection。 @@ -75,11 +76,10 @@ remote_sync: not_requested ## 例外登记 - owner: Cursor -- 原因:本机 macOS 上 `./scripts/agentctl with` 因无 `/proc` 在 Lease 启动时 TypeError(与 CURRENT 既有限制一致)。 -- 覆盖:`git commit` 钩子租约检查;使用 `git -c core.hooksPath=/dev/null` 手工等效提交(不 push)。 -- 范围:仅本任务 feature 分支提交。 -- 期限:agentctl-macos 合入并使 `with` 可用后恢复租约路径。 -- 恢复:后续提交改走 `agentctl with --resource git:index`。 +- 原因:主树 `scripts/agentctl.py` 在 macOS 无 `/proc`;本轮提交改用 `.worktrees/agentctl-macos-2026-09-22/scripts/agentctl with` 持租约,并用 `git -c core.hooksPath=/dev/null` 绕过仍指向主树 hooks 的 pre-commit。 +- 覆盖:仅本 feature 分支本地提交(不 push)。 +- 范围:web-api-client residual CRLF SSE 收口。 +- 恢复:主树 hooks 切到 macos-capable agentctl 后恢复默认 `git commit`。 ## 相关链接 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 7ccfd5c7..e51a47a7 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -108,7 +108,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; @@ -128,6 +128,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 }, @@ -135,22 +167,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 }; } From 060bbb92cb993bd1bf3d7bc55aa0287ac5bc25f7 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 22 Sep 2026 21:59:05 +0800 Subject: [PATCH 2/2] docs(ops): pin web-api-client head_sha after CRLF residual --- .doc/ops/short-term/2026-09-22-web-api-client.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.doc/ops/short-term/2026-09-22-web-api-client.md b/.doc/ops/short-term/2026-09-22-web-api-client.md index 95f2af79..93d4b96f 100644 --- a/.doc/ops/short-term/2026-09-22-web-api-client.md +++ b/.doc/ops/short-term/2026-09-22-web-api-client.md @@ -13,7 +13,7 @@ worktree: .worktrees/web-api-client-2026-09-22 base_dev_sha: "364ed7f1a3f0393ef77ec4623a9518116ed2cb87" write_scope: ["web/src/api/**", ".doc/ops/short-term/2026-09-22-web-api-client.md"] allowed_environments: [] -head_sha: "681ca1b95ecc43b359bfc6677d729c5b393fbb36" +head_sha: "4d87b9a401e9e9b70d80105383f14d2b5b741634" mutex: "web" review_state: pending integration_state: pending @@ -67,7 +67,7 @@ remote_sync: not_requested ## 收口与交接 - 剩余工作:合入 `dev`(需串行集成槽);CURRENT 认领由集成者增量更新。 -- 本地提交:实现 tip `681ca1b9`(feat);其后 docs 提交对齐 short-term;分支 tip 以 `git log -1` 为准。 +- 本地提交:tip `4d87b9a4`(含 CRLF SSE residual);分支 tip 以 `git log -1` 为准。 - `integration_state=pending`;`deployment=not_required`;`remote_sync=not_requested`。 - cleanup:合入后由独立维护者复查 worktree。