fix(e2e): accept already-completed squad turns without waiting inflight
UI-11 can finish before the waiter sees pending. Treat success terminals as done, keep upload/correspondence/error checks, and cover both timelines. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
- 处理中状态仅作可选观测,不作为成功必要条件
|
||||
@@ -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。
|
||||
|
||||
+6
-4
@@ -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/<slug>` 或 `fix/<slug>`,**禁止** `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) |
|
||||
|
||||
@@ -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**
|
||||
@@ -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/`
|
||||
@@ -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 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user