diff --git a/.doc/audit/2026-09-13-document-revision-fast-completion-observation.md b/.doc/audit/2026-09-13-document-revision-fast-completion-observation.md new file mode 100644 index 00000000..d4cc7c8a --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-fast-completion-observation.md @@ -0,0 +1,47 @@ +--- +title: "UI-11 快速 completed 被验收脚本误判为失败" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# UI-11 快速 completed 被验收脚本误判为失败 + +## 现象 + +第14轮真实 UI(schema 30,`dev@339e871`)在 **UI-11** 失败:页面上传附件并发送「请将此附件归档」后,相邻助手卡片已快速处于 `completed`(文案:已收录补充材料并关联黑板)。 + +`waitForSquadCompletion` 默认 `requireInflight=true`,用 Playwright `toHaveAttribute` 硬等 `pending|streaming_status|auto_retrying` 60 秒。收到的属性已是 `completed`,被当成 unexpected value,60s 后 FAIL。产品终态成功,验收误判。 + +## 根因分析 + +| 层 | 问题 | 后果 | +|---|---|---| +| 验收契约 | `requireInflight !== false` 默认为必须先看到处理中 | 快路径成功被 60s 超时打死 | +| Playwright 匹配 | `toHaveAttribute` 对已是 `completed` 的节点持续重试同一错误匹配 | 日志里 123 次 resolved 到 completed 仍等 inflight | +| UI-03 先例 | 已用 `{ requireInflight: false }` 避开同类竞态 | UI-11 未沿用,默认仍硬等 | +| 真实性 | 现场助手无错误、用户句与相邻卡片对应、上传已走 freeform | 不得放宽上传/对应关系/终态/无错误,只改观测顺序 | + +证据 `error-context.md`:Locator 命中 `data-chat-status="completed"`,Expected `/pending|streaming_status|auto_retrying/`。 + +## 严重度 + +`high` + +真实成功轮次被脚本误杀,阻断后续 UI-12+。 + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-fast-completion-observation.md](../remediation/2026-09-13-document-revision-fast-completion-observation.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-fast-completion-observation.md](../ops/short-term/2026-09-13-document-revision-fast-completion-observation.md) +- 分支: `fix/document-revision-fast-completion-observation` + +## 证据 + +- 失败证据(只读,不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-fast-completion-observation-failure/` + +## 开放问题 + +- 本轮不重跑真实 UI-11;不得标 E2E 通过或任务 `done` +- 处理中状态仅作可选观测,不作为成功必要条件 diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index a44465fd..a4e941b0 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -31,6 +31,14 @@ ### 最新专项审计(2026-09-13) +**文件**: [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) + +**结论**: 第14轮 UI-11 上传归档后助手已 `completed`;验收默认硬等 inflight 60s,把成功终态误判为失败。 + +**整改**: [快速完成观测契约整改](../remediation/2026-09-13-document-revision-fast-completion-observation.md) + +### 质量修复子工作区契约(2026-09-13) + **文件**: [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) **结论**: 第13轮 UI-03 质量门禁正确判缺「阻塞信息/非阻塞信息」;repair TASK 引导 `../../output.md`,模型越界改根文件,本地目标缺失,manifest 停在 70。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index c6a83da3..4af4dd49 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,13 +1,13 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;第13轮 UI-03 质量修复子工作区契约;不执行 env-service 重建 / 不声称 E2E 通过) +**production_ready**: no(DOCREV:schema **30**;第14轮 UI-11 快速完成观测契约;不执行 env-service 重建 / 不声称 E2E 通过) ## 焦点 -1. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(`fix/document-revision-quality-repair-workspace-contract`;第13轮 UI-03 越界改根 output.md;不声称 E2E 通过) -2. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06) -3. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) +1. **DOCREV-RT**: [UI-11 快速完成观测契约](short-term/2026-09-13-document-revision-fast-completion-observation.md)(`fix/document-revision-fast-completion-observation`;第14轮成功终态被硬等 inflight 误杀;不声称 E2E 通过) +2. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03) +3. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06) ## 并行认领 @@ -30,6 +30,7 @@ | Cursor-Auto | document-revision-prelim-output | fix/document-revision-pi-runner-module | - | document-revision-prelim-output | in_progress | | Cursor-Auto | document-revision-editor-hydration-race | fix/document-revision-editor-hydration-race | - | document-revision-editor-hydration-race | in_progress | | Cursor-Auto | document-revision-quality-repair-workspace-contract | fix/document-revision-quality-repair-workspace-contract | - | document-revision-quality-repair-workspace-contract | in_progress | +| Cursor-Auto | document-revision-fast-completion-observation | fix/document-revision-fast-completion-observation | - | document-revision-fast-completion-observation | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -77,3 +78,4 @@ | UI-11 prelim_design 缺 output.md 审计 | [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md) | | UI-06 编辑弹窗 hydration 竞态审计 | [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) | | UI-03 质量修复子工作区契约审计 | [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) | +| UI-11 快速完成观测误判审计 | [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-fast-completion-observation.md b/.doc/ops/short-term/2026-09-13-document-revision-fast-completion-observation.md new file mode 100644 index 00000000..e65712c7 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-fast-completion-observation.md @@ -0,0 +1,54 @@ +--- +title: "UI-11 快速完成观测契约" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-fast-completion-observation +worktree: "-" +mutex: document-revision-fast-completion-observation +--- + +# UI-11 快速完成观测契约 + +## 目标 + +堵住第14轮 UI-11:助手已 `completed`,验收脚本仍硬等处理中 60s,把成功终态误判为失败。只改测试契约,不降真实性。 + +## 范围 + +- `web/src/lib/squadCompletionObservation.ts` 与单测 +- `web/e2e/document-revision-runtime.spec.ts` 的 `waitForSquadCompletion` +- audit / remediation / CURRENT + +## 非目标 + +- 不改产品运行时、不放宽上传/对应关系/终态/无错误 +- 不碰证据目录;不合 dest;不启动/重启容器;不 E2E;不 push +- 不把 stub 或伪造页面当验收 + +## 步骤 + +1. [x] 从 `dev@339e871` 开分支并登记 mutex +2. [x] 观测谓词 + 直接 completed / 经历 inflight 测试 +3. [x] 接线 `waitForSquadCompletion`,保留 UI-11 上传断言 +4. [x] 跑相关单测并原子提交 + +## 验收标准 + +- [x] 已 completed 直接接受,不要求 inflight +- [x] 经历 inflight 再到终态仍接受 +- [x] 错误终态与错位消息拒绝 +- [ ] 现场 UI-11(未跑) + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-fast-completion-observation.md](../../audit/2026-09-13-document-revision-fast-completion-observation.md) +- Remediation: [../../remediation/2026-09-13-document-revision-fast-completion-observation.md](../../remediation/2026-09-13-document-revision-fast-completion-observation.md) + +## 证据 / 备注 + +- mutex:`document-revision-fast-completion-observation`(新 mutex) +- 基线:`dev@339e871` +- 验证:`npx tsx --test src/lib/squadCompletionObservation.test.ts` PASS;`cd web && npm test` PASS(306 + 29) +- **未跑真实 UI-11,不得标 done**;**不得 merge / rebuild / E2E / push** diff --git a/.doc/remediation/2026-09-13-document-revision-fast-completion-observation.md b/.doc/remediation/2026-09-13-document-revision-fast-completion-observation.md new file mode 100644 index 00000000..aa217503 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-fast-completion-observation.md @@ -0,0 +1,48 @@ +--- +title: "UI-11 快速完成观测契约整改" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# UI-11 快速完成观测契约整改 + +## 背景 + +链到 [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md)。 + +只修验收脚本与测试契约,不改产品运行时,不降低真实性。 + +## 目标 + +- 定位到该用户 prompt 的相邻助手卡片已是成功终态(`completed` / `pending_author_confirmation`)时直接接受 +- 仍须校验:消息对应关系、成功终态、无错误;UI-11 另须页面上传证据(chip / freeform 2xx / `doc-free-*` pane) +- 处理中状态只作可选观测,不得硬等 60s +- 两类可重复契约:直接 `completed`、经历 inflight 再到终态 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 0 | 抽取观测谓词 + 改 `waitForSquadCompletion` + 单测 | 直接 completed 接受;经历 inflight 接受;错误终态拒绝;UI-11 上传断言保留 | +| Wave 1 | 占槽后重跑真实 UI-11(本轮不做) | 快完成不再被误判 | + +## 设计 + +1. **先读当前 `data-chat-status`**:成功终态 + 无可见错误 → 立即接受,即使从未看到 inflight。 +2. **仍在处理中**:再等终端态;不把「必须先看到 pending」当作门闩。 +3. **失败终态 / 可见错误 / 找不到相邻助手**:FAIL,不得假通过。 +4. **`requireInflight` 默认关闭**;即使显式打开,已 completed 仍直接接受。 + +## 验收标准 + +- [x] 快照已是 completed → accept,`sawInflight=false` +- [x] pending → completed → accept,`sawInflight=true` +- [x] failed / 无对应关系 / 可见错误 → reject +- [x] UI-11 上传与 freeform 断言不删不降 +- [ ] 真实 UI-11(本轮不做) + +## 相关链接 + +- Audit / Short-term:同 slug `2026-09-13-document-revision-fast-completion-observation` +- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-fast-completion-observation-failure/` diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index eae02cb1..0d06433d 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -3,6 +3,7 @@ | 文档 | 说明 | |------|------| | [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT | +| [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) | UI-11 快完成被硬等 inflight 误杀,只改验收观测契约 | | [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) | 质量修复子工作区越界改根 output.md,本地目标缺失 | | [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) | 编辑弹窗打开期间 hydration 换块 ID,保存静默丢稿 | | [2026-09-13-document-revision-runtime-remediation.md](./2026-09-13-document-revision-runtime-remediation.md) | 账本 PG 持久化、bootstrap、保存/overlay/确定性 change report | diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index fa9f73d6..3efe04c6 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -25,7 +25,7 @@ | 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` | | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` | -| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录 | `ci` | +| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-11 快完成观测](../audit/2026-09-13-document-revision-fast-completion-observation.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;UI-11 验收不得把已 completed 误判为缺 inflight | `ci` | --- diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 5c236de6..c68035df 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -6,6 +6,10 @@ import { BLOCK_EDITOR_DIALOG_LABEL, BLOCK_EDITOR_SAVE_NAME, } from '../src/lib/blockEditorDialog'; +import { + ANY_TERMINAL_STATUS_RE, + evaluateSquadObservation, +} from '../src/lib/squadCompletionObservation'; /** * Document revision runtime — real UI acceptance (UI-01..UI-16). @@ -243,8 +247,8 @@ function turnAssistantLocator(page: Page, userPromptSnippet: string) { } /** - * Wait for this-turn assistant terminal. Optionally require observing inflight first. - * UI-03 may skip inflight (already seen in UI-02) to avoid fast-complete race. + * Wait for this-turn assistant terminal. + * Inflight is optional: if the adjacent card is already a success terminal, accept it. */ async function waitForSquadCompletion( page: Page, @@ -253,35 +257,43 @@ async function waitForSquadCompletion( opts?: { requireInflight?: boolean }, ) { const startedAt = Date.now(); - const requireInflight = opts?.requireInflight !== false; + const requireInflight = opts?.requireInflight === true; const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet); await expect(userBubble).toBeVisible({ timeout: 30_000 }); await expect(assistant).toBeVisible({ timeout: 30_000 }); - if (requireInflight) { - await expect(assistant).toHaveAttribute( - 'data-chat-status', - /pending|streaming_status|auto_retrying/, - { timeout: 60_000 }, + const connErr = page.locator('text=连接异常'); + const observe = async () => { + const status = (await assistant.getAttribute('data-chat-status')) || ''; + const hasVisibleError = await connErr.first().isVisible().catch(() => false); + return evaluateSquadObservation( + { + userPromptMatched: true, + assistantAdjacent: true, + status, + hasVisibleError, + }, + { requireInflight }, ); + }; + + let verdict = await observe(); + if (verdict.action === 'reject') { + fail(`squad observation rejected: ${verdict.reason}`); + } + if (verdict.action === 'accept') { + return Date.now() - startedAt; } - await expect(assistant).toHaveAttribute( - 'data-chat-status', - /completed|pending_author_confirmation|failed|cancelled|quality_failed/, - { timeout: timeoutMs }, - ); - - const terminal = (await assistant.getAttribute('data-chat-status')) || ''; - if ( - terminal === 'failed' || - terminal === 'cancelled' || - terminal === 'quality_failed' - ) { - fail(`squad terminal status is ${terminal}`); + await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, { + timeout: timeoutMs, + }); + verdict = await observe(); + if (verdict.action === 'reject') { + fail(`squad observation rejected: ${verdict.reason}`); } - if (terminal !== 'completed' && terminal !== 'pending_author_confirmation') { - fail(`squad terminal status unexpected: ${terminal}`); + if (verdict.action !== 'accept') { + fail(`squad terminal not accepted: ${verdict.reason}`); } return Date.now() - startedAt; } @@ -1142,7 +1154,8 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { fail(`UI-11 must not leave doc-upload orphan active: ${uploadedDocId}`); } - // 发送会拉起普通 assistant:离开 UI-11 前等本轮终态,避免污染 UI-12+。 + // 本轮助手可能直接 completed(归档快路径)。仍须:上传证据、相邻卡片、成功终态、无错误。 + // inflight 只作可选观测,不得因错过 pending 而把 completed 判失败。 await waitForSquadCompletion(page, '请将此附件归档', 180_000); await shot(page, 'UI-11-multi-doc.png'); diff --git a/web/src/lib/squadCompletionObservation.test.ts b/web/src/lib/squadCompletionObservation.test.ts new file mode 100644 index 00000000..0b229a71 --- /dev/null +++ b/web/src/lib/squadCompletionObservation.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { reduceSquadObservation } from './squadCompletionObservation'; + +const turn = { + userPromptMatched: true, + assistantAdjacent: true, + hasVisibleError: false, +}; + +describe('squadCompletionObservation', () => { + it('accepts a directly completed adjacent assistant without requiring inflight', () => { + const verdict = reduceSquadObservation([{ ...turn, status: 'completed' }], { + requireInflight: true, + }); + assert.equal(verdict.action, 'accept'); + assert.equal(verdict.reason, 'already_completed'); + assert.equal(verdict.sawInflight, false); + }); + + it('accepts a turn that passed through inflight then completed', () => { + const verdict = reduceSquadObservation( + [ + { ...turn, status: 'pending' }, + { ...turn, status: 'streaming_status' }, + { ...turn, status: 'completed' }, + ], + { requireInflight: true }, + ); + assert.equal(verdict.action, 'accept'); + assert.equal(verdict.reason, 'completed_after_inflight'); + assert.equal(verdict.sawInflight, true); + }); + + it('rejects error terminals, missing correspondence, and visible errors', () => { + assert.equal( + reduceSquadObservation([{ ...turn, status: 'failed' }]).action, + 'reject', + ); + assert.equal( + reduceSquadObservation([{ ...turn, userPromptMatched: false, status: 'completed' }]) + .action, + 'reject', + ); + assert.equal( + reduceSquadObservation([{ ...turn, assistantAdjacent: false, status: 'completed' }]) + .action, + 'reject', + ); + assert.equal( + reduceSquadObservation([{ ...turn, hasVisibleError: true, status: 'completed' }]) + .action, + 'reject', + ); + }); + + it('keeps waiting while still inflight and does not treat inflight as success', () => { + const verdict = reduceSquadObservation([{ ...turn, status: 'pending' }]); + assert.equal(verdict.action, 'wait'); + assert.equal(verdict.sawInflight, true); + }); +}); diff --git a/web/src/lib/squadCompletionObservation.ts b/web/src/lib/squadCompletionObservation.ts new file mode 100644 index 00000000..8113bbdc --- /dev/null +++ b/web/src/lib/squadCompletionObservation.ts @@ -0,0 +1,97 @@ +export const INFLIGHT_CHAT_STATUSES = ['pending', 'streaming_status', 'auto_retrying'] as const; +export const SUCCESS_TERMINAL_CHAT_STATUSES = ['completed', 'pending_author_confirmation'] as const; +export const ERROR_TERMINAL_CHAT_STATUSES = ['failed', 'cancelled', 'quality_failed'] as const; + +export const SUCCESS_TERMINAL_STATUS_RE = + /^(completed|pending_author_confirmation)$/; +export const ERROR_TERMINAL_STATUS_RE = /^(failed|cancelled|quality_failed)$/; +export const ANY_TERMINAL_STATUS_RE = + /^(completed|pending_author_confirmation|failed|cancelled|quality_failed)$/; + +export type SquadObservationFrame = { + userPromptMatched: boolean; + assistantAdjacent: boolean; + status: string; + hasVisibleError?: boolean; +}; + +export type SquadObservationVerdict = { + action: 'accept' | 'wait' | 'reject'; + reason: string; + sawInflight: boolean; +}; + +export function isInflightChatStatus(status: string): boolean { + return (INFLIGHT_CHAT_STATUSES as readonly string[]).includes(status); +} + +export function isSuccessTerminalChatStatus(status: string): boolean { + return (SUCCESS_TERMINAL_CHAT_STATUSES as readonly string[]).includes(status); +} + +export function isErrorTerminalChatStatus(status: string): boolean { + return (ERROR_TERMINAL_CHAT_STATUSES as readonly string[]).includes(status); +} + +/** + * Decide whether a single observed assistant card is done, still running, or invalid. + * Inflight is optional: a success terminal is accepted even if requireInflight is true + * and no pending/streaming frame was seen (UI-11 fast-complete). + */ +export function evaluateSquadObservation( + frame: SquadObservationFrame, + ctx?: { requireInflight?: boolean; sawInflight?: boolean }, +): SquadObservationVerdict { + const sawInflight = Boolean(ctx?.sawInflight) || isInflightChatStatus(frame.status); + if (!frame.userPromptMatched) { + return { action: 'reject', reason: 'missing_user_prompt', sawInflight }; + } + if (!frame.assistantAdjacent) { + return { action: 'reject', reason: 'missing_adjacent_assistant', sawInflight }; + } + if (frame.hasVisibleError) { + return { action: 'reject', reason: 'visible_error', sawInflight }; + } + if (isErrorTerminalChatStatus(frame.status)) { + return { action: 'reject', reason: `error_terminal:${frame.status}`, sawInflight }; + } + if (isSuccessTerminalChatStatus(frame.status)) { + return { + action: 'accept', + reason: sawInflight && !isInflightChatStatus(frame.status) + ? 'completed_after_inflight' + : 'already_completed', + sawInflight: Boolean(ctx?.sawInflight), + }; + } + if (isInflightChatStatus(frame.status)) { + return { action: 'wait', reason: 'inflight', sawInflight: true }; + } + return { action: 'wait', reason: 'wait_terminal', sawInflight: Boolean(ctx?.sawInflight) }; +} + +/** Replay a status timeline. Used by unit tests for direct-completed vs inflight paths. */ +export function reduceSquadObservation( + frames: SquadObservationFrame[], + opts?: { requireInflight?: boolean }, +): SquadObservationVerdict { + let sawInflight = false; + let last: SquadObservationVerdict = { + action: 'wait', + reason: 'empty_timeline', + sawInflight: false, + }; + for (const frame of frames) { + if (isInflightChatStatus(frame.status)) { + sawInflight = true; + } + last = evaluateSquadObservation(frame, { + requireInflight: opts?.requireInflight, + sawInflight, + }); + if (last.action !== 'wait') { + return last; + } + } + return last; +}