diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 9109f11..4f6e23c 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -15,9 +15,9 @@ | AI 编程模型代理错误 | OpenCode provider 请求 | Main Host AI proxy → Works 模型上游 | 配额耗尽保持独立终止态;只有明确的分组上游饱和才投影为 OpenCode 终止状态,通用限速仍保留 `429` | | 客户端更新检查 | 设置页 | Renderer update store → IPC → Main AppUpdater → 目标 feed | Main 记录并重抛原始错误;Renderer 只显示去重、脱敏的单条提示,稳定源缺包不伪装为最新版 | | 设计会话创建/切换 | AI 绘画页面或侧栏 | Renderer API → Main → Workspace Conversation API | 新会话属于现有 Workspace;读取独立消息、Brief、Quote 和 `turnRevision` | -| 设计消息与确认 | 当前 Conversation | Main → 持久 Agent Gateway Session → Conversation 快照 | 请求和流式结果同时绑定 Workspace + Conversation | +| 设计消息与确认 | 当前 Conversation | Main ↔ 持久 Agent Gateway Session WebSocket → Conversation 快照 | `command.submit`、Run 与设计事件共用连接;传输失败才以同一幂等 ID 回退 REST;结构化业务错误不重试且未知文本由 Main 脱敏;请求和流式结果同时绑定 Workspace + Conversation | | 设计单图来源选择 | 当前 Workspace 已完成图片 / 本地图片 | 现有 Asset 上传或选择 → `attachmentAssetIds` → 当前 Conversation Turn | 图片 Brief 用作图生图参考图;视频 Brief 用作首帧;只提交一个真实 Workspace Asset ID | -| 设计任务同步 | 任一 Conversation 的事件流 / REST | Renderer Workspace 任务列表 | Task 和 Asset 按 Workspace 归并,切换 Conversation 后仍可见 | +| 设计任务同步 | 任一 Conversation 的事件流 / Quote REST 对账 | Renderer Workspace 任务列表 | Task 和 Asset 按 Workspace 归并;任务已落库但 Run 失败时恢复可见性,内部对账失败不覆盖新会话错误,切换 Conversation 后仍同步任务但不回写旧会话 | ## State Ownership @@ -41,8 +41,8 @@ - 本机 Host API 的 OpenCode session、history 与 prompt 路由,以及 Main-owned AI 模型代理。 - Main-owned electron-updater IPC 与 Works Square 平台/架构稳定 feed;正式安装产物发布不由 Renderer 控制。 - 服务端安全投影后的公共 `play_url`;只接受同源 HTTPS、精确 App 路径和可信版本状态。 -- Works Square Workspace/Conversation API、每个 Conversation 的 Agent Gateway Session 与单次 WebSocket ticket。 +- Works Square Workspace/Conversation API、每个 Conversation 的持久 Agent Gateway Session、单次 WebSocket ticket、双向命令/事件帧与幂等 REST 传输回退。 ## Last Updated -2026-08-13 +2026-08-14 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 0045bb3..4f1048e 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -23,9 +23,9 @@ | `src/components/settings/UpdateSettings.tsx` | 更新状态、重试与用户可读错误展示 | 只显示一条简洁中文提示;技术诊断统一回退到本地化通用文案 | | `shared/image-workspace.ts` | AI 绘画 Workspace、Conversation、Task、Asset 与事件共享契约 | Conversation 状态与 Workspace 任务归属必须分离 | | `electron/api/routes/image-workspace.ts` | AI 绘画 Host API 与本地事件流投影 | Renderer 只通过该路由访问 Main-owned workspace adapter | -| `electron/image-workspace/works-square-workspace.ts` | Works Square 多 Conversation 云端适配器与 Gateway 事件映射 | 使用服务端持久 Session;本地清理不 DELETE 远端 Session | +| `electron/image-workspace/works-square-workspace.ts` | Works Square 多 Conversation 云端适配器与双向 Gateway 命令/事件映射 | 使用服务端持久 Session;WebSocket 传输故障才以同一幂等 ID 回退 REST;未知 Gateway 错误文本不得穿透安全投影;本地清理不 DELETE 远端 Session | | `electron/image-workspace/local-workspace.ts` | 未打包开发模式的本地 Workspace 适配器和 v2→v3 迁移 | 仅开发使用,不得成为打包回退 | -| `src/stores/image-workspace.ts` | 当前 Workspace/Conversation、项目任务及流式更新状态 | 异步结果按 Workspace + Conversation generation/revision 防护 | +| `src/stores/image-workspace.ts` | 当前 Workspace/Conversation、项目任务及流式更新状态 | Quote/task 按 Workspace 无 UI 错误副作用地对账;Conversation 写入按 Workspace-load + Conversation-selection generation/revision 防护 | | `src/pages/ImageCanvas/index.tsx` | Conversation 对话、Quote 确认、统一任务列表、新会话入口与单图来源选择器 | 图片 Brief 选择/上传图生图参考图;视频 Brief 绑定首帧;均提交一个 Workspace Asset ID | | `src/components/layout/ImageWorkspaceSidebar.tsx` | Workspace 与近期 Conversation 切换/创建 | 切换会话不清空项目级任务 | @@ -46,9 +46,10 @@ - `ai-proxy.ts` 的上游饱和状态投影依赖当前固定 OpenCode 的重试语义和窄化错误文案;升级 runtime 或调整上游错误格式时必须复核,不能把所有 `429` 统一终止。 - `electron/main/updater.ts` 的稳定源错误归一化必须保持窄化:只识别 Works Square 对应 manifest 的 404,不得吞掉其他 feed/网络/签名错误;Renderer 的脱敏边界不能取代 Main 原始日志。 - 多 Conversation 事件处理必须区分对话快照与 Workspace 任务更新;不得用任务时间戳推进 Conversation 流水位,也不得让旧会话的迟到流覆盖当前会话。 +- Gateway 命令的 REST fallback 只处理 WebSocket 发送、断连和 ACK 超时,必须复用 `client_command_id`;业务错误回退会造成重复提交。Quote 任务恢复只更新 Workspace 所有的任务,不能覆盖当前 Conversation。 - `closeEventSessions` 只负责本地流和缓存生命周期;远端 Conversation Session 是服务端持久资源。 - 单图来源选择器当前仍由精确中文 quick reply 触发,并以 Brief medium 判断图生图或视频首帧用途;扩展更多输入用途前应先把消息协议升级为结构化 action/purpose,避免展示文案与行为继续耦合。 ## Last Updated -2026-08-13 +2026-08-14 diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index e48066c..9f18e01 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -20,7 +20,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | AI Design Workspace | 保存项目身份、Conversation 列表、生成任务和资产 | 任务和资产在切换 Conversation 后继续可见 | | AI Design Conversation | 保存消息、Brief、Quote、`turnRevision` 与服务端 Agent Session 绑定 | 同一 Workspace 内互相隔离;Session 由服务端持久化 | | AI Design Image Source Picker | 当前 Workspace 已完成图片或本地上传 | 图片 Brief 绑定图生图参考图;视频 Brief 绑定首帧;提交一个真实 Asset ID | -| AI Design Event Routing | Main 云端适配器 → Host API/SSE → Renderer store | Conversation 更新按 Workspace + Conversation 路由;任务更新按 Workspace 归并 | +| AI Design Gateway Routing | Main 云端适配器 ↔ Conversation WebSocket;Main → Host API/SSE → Renderer store | 命令、Run 与设计事件共用双向 WebSocket;Conversation 更新按 Workspace + Conversation 路由,任务更新按 Workspace 归并 | | Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections; credentials, activation-code history, and upstream error bodies remain outside durable UI state. | | AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, and error redaction. It never forwards Renderer authorization headers. | @@ -45,7 +45,9 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - 落盘文件名 `works-cloud-deploy.json` 仅为已安装客户端的数据兼容;领域模型和代码接口是 submission binding,不表示仍存在 cloud deployment coordinator。 - AI 绘画中,一个 Workspace 可包含多条 Conversation;消息、Brief、Quote 和 `turnRevision` 属于 Conversation,生成任务和资产属于 Workspace。新建 Conversation 不得隐式创建新 Workspace。 - 每条 Conversation 复用服务端持久 Agent Gateway Session。客户端不得在注销或退出时 DELETE 该 Session;只关闭本地事件流并清除本机 Session-id 缓存,重新访问时从 Conversation API 刷新。 +- 已连接的 Conversation Session 通过同一 WebSocket 提交 `command.submit` 并接收 `command.accepted`、Run 与设计事件。只有发送失败、连接关闭或 ACK 超时属于可回退的传输故障,REST 必须复用同一 `client_command_id`;结构化 Gateway 业务错误不得再次提交,未知上游错误文本不得穿透 Main 安全投影。 - Renderer 的异步与流式状态必须同时校验 Workspace + Conversation 身份;项目任务事件只按 Workspace 归并,避免切换会话时丢失任务或接收迟到消息。 +- 确认生成按 Quote 对账 Workspace 任务。任务事务已提交但 Run 随后失败时可通过 REST 恢复任务可见性;内部任务对账失败不写当前 Conversation 的用户错误,任务恢复也不授权旧请求回写已切换的 Conversation 或清理新 pending turn。 - Updater feed 选择、原始错误日志、下载和安装生命周期只属于 Electron Main。Renderer 不得把缺失稳定 manifest 投影为“已是最新版”,也不得显示原始堆栈、URL、路径或错误码;并发检查共享同一错误事件时只发送一次错误状态,后续独立重试仍可重新报告。 - 图片与视频复用同一个单图来源选择器。图片 Brief 可从当前 Workspace 的已完成作品或本地上传中选择一张参考图继续生成;视频 Brief 使用同一入口绑定首帧。两条路径都必须通过现有 Workspace Asset 上传/选择契约提交一个真实 `attachmentAssetIds`,不得用本地路径或自然语言描述代替资产身份。 - 当前兼容协议仍通过精确 quick reply `从作品列表选择图片` 打开选择器,并由当前 Brief medium 区分用途;仅 `medium === image` 使用图生图语义,`video`、null 或缺失 medium 保持视频首帧语义。该展示文案耦合不得扩散到更多用途,后续应升级为结构化 action/purpose。 @@ -56,4 +58,4 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 ## Last Updated -2026-08-13 +2026-08-14 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 8719db1..f2d32c1 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- `22378efcee07e7fb80b651e65e3202f1a1dfea1d`: AI Canvas bidirectional Agent WebSocket commands, idempotent transport fallback, and Quote-based generation-task recovery. - `aba5cae286807093cf4ef643fe9f498050985c31`: Robot / AI hardware module, Main-owned Works Square proxy, and cross-repository wire contract. - `86ece3a` / `4dde8f3`:客户端登录七天滑动续期及集成提交。 - `724290e` / `dcc92fc`:Main-owned 一键打包提交审核及集成提交。 @@ -21,7 +22,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre 客户端面向非专业用户提供“创建小游戏或小程序 → 项目配置中一键提交 → Main 本地 npm/Vite 构建 → Electron 双视口预检最终产物 → 上传 source+built 双归档与 contract → 运营审核”的唯一创建者链路。Main 对安全源码快照运行安装包内固定 npm 11.6.2 的 `npm ci --ignore-scripts`,再显式调用项目 `package-lock.json` 锁定的 Vite;Vite config/plugins 以当前桌面用户权限执行,因此只适用于用户信任的本地项目,不是 sandbox。预检由 Main 以临时 loopback origin 和 Electron WebContents/CDP 检查与最终 `built_archive` 相同的内存文件字节,覆盖桌面/移动视口、运行错误、白屏和外域访问;不使用 Playwright。该检查仍可由非官方客户端绕过,不产生可信 receipt,也不复刻生产 opaque-origin。服务端不再替客户端运行项目 Vite,而是把源码、构建归档和 contract 视为不可信输入,逐字节重算与校验并固化不可变 Release;人工审核仍不可绕过。自定义和缺少类型字段的旧项目不提供该入口。已发布作品优先使用安全投影后的 `play_url`,`runtime_url` 仅保留一个客户端版本的兼容回退。 -AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。图片 Brief 支持文生图,以及从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频复用同一选择器绑定首帧。两条路径都通过现有 Workspace Asset 契约提交一个真实 Asset ID。每条 Conversation 使用服务端持久 Agent Gateway Session,切换会话只重连对应事件流。 +AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。图片 Brief 支持文生图,以及从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频复用同一选择器绑定首帧。两条路径都通过现有 Workspace Asset 契约提交一个真实 Asset ID。每条 Conversation 使用服务端持久 Agent Gateway Session;连接正常时命令、Run 与设计事件共用双向 WebSocket,只有发送、断连或 ACK 超时等传输故障才以同一 `client_command_id` 回退 REST,结构化业务错误不重复提交且未知上游文本由 Main 脱敏。确认生成会按 Quote 对账 Workspace 任务;任务已经落库但 Run 随后失败时仍恢复任务列表,内部对账失败不覆盖当前 UI 错误,同时 Conversation 写入继续受 Workspace-load 与 Conversation-selection generation 保护。 AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`。 @@ -29,6 +30,7 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 ## Recently Completed +- 2026-08-14: AI Canvas Agent 命令与流式事件改为共用双向 Conversation WebSocket,并保留仅面向传输故障的幂等 REST 回退;结构化 Gateway 错误不重试且未知文本脱敏;确认生成按 Quote 恢复已落库任务,切换 Conversation 后仍同步 Workspace 任务且不覆盖新会话状态或错误。 - 2026-08-13: Integrated the enabled `Makelore Robot` top-level module at `/ai-hardware`. Renderer uses typed safe DTOs while Electron Main owns Works Square authentication, idempotency identities, ETag/If-Match forwarding, response projection, limits, timeouts, and credential recovery. Robot and Canvas routes no longer initialize AI Programming projects/providers. No production deployment or real activation-code smoke is claimed. - 2026-08-08:合并登录续期和一键发布;发布成功后保存精确 app/version/review 映射,Renderer 不接触 Token、ZIP 或本地路径。 - 2026-08-08:补齐跨平台 Electron E2E fixture、Windows ZIP 预检和异常成功响应安全投影。 @@ -66,6 +68,7 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 - 本地构建必须使用安装版 Electron Node、固定 npm 11.6.2 和项目 `package-lock.json` 锁定的 Vite,不得回退到全局 PATH、已有 `node_modules` 或未验证的 npm 闭包;依赖安装需要网络。 - 项目 Vite config/plugins 以桌面用户权限执行,不能称为 sandbox;此风险边界必须在发布说明中保留。 - AI 绘画消息、Brief、Quote 和 `turnRevision` 必须按 Workspace + Conversation 隔离;任务事件仍按 Workspace 归并,迟到的异步结果不得污染已切换的会话。 +- AI 绘画 WebSocket 命令只能在传输发送、连接关闭或 ACK 超时时回退到 REST,并必须复用原 `client_command_id`;Gateway 结构化业务错误不得触发重复提交,未知错误文本不得进入 Renderer。确认后的任务恢复必须按不可变 Quote 身份归并到 Workspace,不能用任务恢复放宽 Conversation generation 门禁或覆盖新会话错误。 - 服务端持久 Conversation Session 不由客户端在注销或退出时删除;Main 只关闭本地流、清除本机缓存并撤销本地凭据。 - 客户端对最终构建字节的 loopback 检查没有可信 receipt,且不复刻生产 opaque-origin;服务端必须独立重算 source/built/contract、校验不可变 Release,人工审核仍不可绕过。如未来需要不可绕过的 runtime gate,必须引入可信 verifier 并绑定精确构建产物。 - AI proxy 的明确饱和 `429 → 400` 是与当前 OpenCode 重试契约匹配的内部投影;升级 OpenCode 或上游饱和错误文案变化时必须复核。不得把通用 `429` 或仅含 `rate_limit_exceeded` 的响应误分类为终止饱和。 @@ -74,4 +77,4 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 ## Last Updated -2026-08-13 +2026-08-14 diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md index d6284ca..1729c7e 100644 --- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md +++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md @@ -12,6 +12,7 @@ ## Scope +- On 2026-08-14, resume the existing Integration owner to merge reviewed AI Canvas source commit `22378efcee07e7fb80b651e65e3202f1a1dfea1d` into local `main` and promote its accepted project-memory candidates. - Resume the existing integration owner to fast-forward the completed Robot / AI hardware source commit into local `main` after verifying the current remote `main` tip. - Fetch the authoritative remote `main`, inspect all commits not present in the local branch, and merge them into the reviewed local `main` without rewriting @@ -35,6 +36,7 @@ semantic reconciliation. - Push only after the merge result is clean, verified, and independently reviewed. Never use `--force`. +- The 2026-08-14 user request authorizes the local `main` merge only. It does not expand this resumption into a remote push; the existing authentication blocker remains a separate follow-up. ## Project Context Loaded @@ -82,16 +84,22 @@ Relevant understanding: Gate result: - Passed. +### 2026-08-14 AI Canvas Integration Resume + +- Reused the same Integration owner because it still exclusively owns `main` and the repository integration lock; `task_context.py touch` refreshed the existing reservation. +- Verified the main worktree was clean at `88f9ee8708beeb1ab45ca741807f6cca0f075ac9` before merging. +- `git fetch origin main --prune` confirmed `origin/main` is also `88f9ee8708beeb1ab45ca741807f6cca0f075ac9`. +- Source `22378efcee07e7fb80b651e65e3202f1a1dfea1d` is exactly one commit ahead of `main`, with `88f9ee8` as merge base; no remote-only or unrelated main commit exists. +- Read the source task, ADR-001, canonical AI Canvas architecture/domain memory, and all registry-owned peer task Scope / Intent / Promotion Candidate sections. The source preserves Workspace-owned tasks and Conversation-owned state; no semantic conflict requires human resolution. +- Gate result: Passed for the local AI Canvas merge. Remote push remains outside this resumption. + ## Plan -1. Commit this integration ownership record, fetch `origin/main`, and inspect - remote-only commits, paths, and topology. -2. Merge `origin/main` normally with local `main` as first parent; resolve any - conflict semantically and inspect the combined tree. -3. Run focused/full checks required by the fetched changes plus project-document - gates, then obtain a final independent Sol PASS/FAIL review. -4. Complete documentation, push `main` without force, verify local/remote tip - equality, and release the clean integration task. +1. Fetch `origin/main` and verify local `main`, the remote tip, and reviewed AI Canvas source commit topology. +2. Merge `22378ef` normally into local `main` without rebase/reset, and retain the source commit as a dedicated second parent. +3. Promote the bidirectional Gateway and Quote-reconciliation facts into canonical project memory. +4. Re-run focused/full checks, production build, project-document gates, and independent Sol review. +5. Commit the verified local merge; do not push unless the user separately requests it and authentication is available. ## Outcome @@ -149,6 +157,13 @@ Gate result: - Completion is blocked only on the user authenticating this machine for `git.nianxx.cn`. Keep this integration task owned and do not release it until a normal push succeeds and `origin/main` is verified equal to local `HEAD`. +- On 2026-08-14, the user requested a local-main merge of reviewed AI Canvas source `22378ef`. A fresh fetch verified local and remote `main` were both `88f9ee8`, and the source was exactly one descendant commit with no unrelated overlap. +- Started a normal `--no-ff --no-commit` merge of `22378ef`; Git reported no textual conflict. The source task record remains intact on source commit `22378ef` and its feature branch, and is excluded from the `main` result to preserve task-document ownership boundaries. +- Promoted the accepted source facts into canonical current-state, architecture, data-flow, business-rule, evidence, and commitment records: connected Conversation commands/events are bidirectional over WebSocket; REST fallback is transport-only and idempotent; Quote task recovery remains Workspace-owned while Conversation writes retain generation guards. +- Merge validation exposed a pre-existing AI Hardware test race: the edit button is rendered disabled while configuration loads, but four tests clicked it after waiting only for existence. Under full-suite load the browser correctly ignored the disabled click. The tests now wait for the button to become enabled and for the dialog heading; production Robot behavior is unchanged. +- The first independent AI Canvas merge review returned `FAIL` on two integration gaps and one safety subfinding: stale confirmation task-refresh errors could overwrite a newer Conversation error, top-level structured WebSocket command errors lacked focused coverage, and unknown Gateway messages could expose upstream details. Confirmation reconciliation now uses an internal task refresh without UI-error side effects; explicit user refreshes retain their error behavior. Matching WebSocket errors are tested as non-retryable, and unknown codes project a fixed Chinese fallback instead of the server message. +- The second independent Sol review returned `PASS` with no blocking findings after those corrections. The verified no-ff merge is ready to commit with `88f9ee8` as first parent and `22378ef` as second parent. +- This resumption intentionally does not push. The previous remote authentication follow-up remains unchanged and does not block completion of the user's requested local merge. ## Verification @@ -172,6 +187,19 @@ Gate result: - Merge topology — verified first parent `1c85bc0`, second parent `f4113a8`. - Push — attempted normally, rejected before ref update because remote authentication is unavailable on this machine. +- 2026-08-14 AI Canvas focused selection — 3 files / 74 tests passed on the merged `main` tree. +- AI Hardware readiness regression — full `ai-hardware-page.test.tsx` passed 24/24 after replacing existence-only clicks with enabled/dialog readiness waits. +- `pnpm test` — the original full-suite loop failed twice at the same disabled-button race before the test hardening; the post-fix run passed 156 files / 1687 tests. +- `pnpm run typecheck` — passed after the integration test hardening. +- `pnpm run lint:check` — passed with 0 errors and 6 unchanged warnings outside the merge paths. +- `pnpm run build:vite` — Renderer, Electron Main, and Preload production builds passed; existing chunk-size and mixed-import warnings remain. +- Post-review focused checks — Works Square adapter 27/27 and Image Workspace Store coverage passed, including top-level WebSocket errors, unknown-message redaction, A→B task-refresh failure isolation, and explicit-refresh error reporting. +- Final post-review `pnpm test` — 156 files / 1691 tests passed. +- Final post-review `pnpm run typecheck` — passed. +- Final post-review `pnpm run lint:check` — passed with 0 errors and the same 6 warnings outside the merge paths. +- Final post-review `pnpm run build:vite` — Renderer, Electron Main, and Preload passed; only the existing mixed-import and chunk-size warnings remain. +- Second independent final review — `PASS`; it re-ran 4 files / 102 tests plus typecheck, found no unmerged entries or unstaged changes, and confirmed the previous state-isolation, structured-error coverage, and redaction findings are closed. +- Final project-document ownership drift, structure checks, and staged/unstaged whitespace checks — passed before the merge commit. ## Follow-ups diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index d40f646..afcb920 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -24,6 +24,8 @@ - Conversation 持有消息、Brief、Quote 和 `turnRevision`;生成任务和资产属于 Workspace,切换 Conversation 后必须继续可见。 - 每条 Conversation 使用服务端持久 Agent Gateway Session。客户端注销或退出时只关闭本地流并清除本机 Session-id 缓存,不删除服务端 Session。 - Conversation 异步与流式更新必须同时核对 Workspace 和 Conversation 身份;任务事件按 Workspace 归并。 +- 已连接的 Conversation Agent 命令、Run 和设计事件共用 WebSocket。只有命令发送失败、连接关闭或 ACK 超时可用同一 `client_command_id` 回退 REST;Gateway 结构化业务错误不得重复提交。Main 只向 Renderer 投影已知错误码的固定中文提示,未知上游错误文本必须脱敏为通用提示。 +- 确认生成后按 Quote 对账 Workspace 任务。任务已经落库但 Run 随后失败时应恢复右侧任务列表;切换 Conversation 后仍同步 Workspace 任务,但旧确认不得覆盖当前 Conversation、清除其 pending turn,或用任务对账失败覆盖新会话错误。 - 图片生成支持无参考图的文生图和单参考图图生图;参考图必须来自当前 Workspace 的已完成图片 Asset,或先通过现有 Workspace 上传接口把本地图片转成 Asset,再随当前 Conversation Turn 提交唯一 Asset ID。 - 视频首帧与图生图参考图复用单图选择器,但用途由当前 Brief medium 决定:只有明确 `image` 使用图生图语义,`video`、null 或缺失值保持视频首帧兼容语义。选择作品或上传成功后应关闭弹窗。 - 当前 quick reply 文案匹配只是兼容契约;新增更多素材输入用途前,应改为结构化 action/purpose,不得继续依赖本地化展示字符串推断行为。 @@ -36,7 +38,8 @@ - 多 Conversation 客户端发布前需确认服务端迁移 `0033`、Conversation API 与持久 Session 契约已经部署。 - 图生图随客户端发布前,需确认相匹配的服务端 `image_to_image` Brief/Quote/Task 冻结、私有源图复核与 Bailian edit 执行链已部署,并使用真实 Workspace Asset 完成生产 smoke。 - Updater 生产恢复仍需对齐权威版本、发布正式签名/公证的平台产物,并从旧安装版本执行发现、下载、重启和安装 smoke;源码提示修复本身不构成发布链恢复。 +- AI Canvas 双向 Gateway 与 Quote 任务恢复仍需真实账号执行一次生产确认 smoke,核对 WebSocket 不产生 `/runs/{run_id}` 轮询、任务按 Quote 出现在 Workspace 列表,并区分真正的 `agent_runtime_unavailable` worker 故障。 ## Last Reviewed -2026-08-13 +2026-08-14 diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 9a0cc6a..18529a9 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -11,6 +11,7 @@ Use this index for searchable, traceable evidence records. | 2026-08-12 | AI 编程首次会话等待与上游饱和重试 | 本地功能验证通过;真实冷启动各阶段耗时尚未遥测 | 源任务 `20260812-first-chat-fix-b84fd29c`、`08da976`、`0ee5254` | 4 个相关单测文件 228/228、typecheck、scoped lint、Vite build 与真实 Electron E2E 通过;全量单测的 3 个失败与 `main` 基线一致,修复证明 prompt 不等待已知空历史,但不宣称消除所有 runtime 冷启动耗时 | | 2026-08-13 | AI 绘画单参考图图生图客户端 | 本地功能验证通过;未执行安装包或真实 Provider smoke | 源任务 `20260812-design-image-to-image-client-c91e`、`809364e`、集成 `88281b8`、兼容修复 `7a807a2` | 25 项页面回归覆盖图片作品选择、本地上传、视频首帧及 null-medium 兼容;22 项云端适配器回归覆盖 REST/WebSocket 缺失 medium 归一化与非法值拒绝;typecheck、scoped lint 与 Vite build 通过,生产部署与付费生成另行验收 | | 2026-08-13 | Makelore Updater 稳定源诊断与错误展示 | 本地功能与构建验证通过;未发布或安装正式包 | `30-worklog/tasks/20260813-makelore-updater-client-7d3a9c.md`、`f05b9d4` | 4 files / 37 focused tests、typecheck、scoped lint 与 `build:vite` 通过;并发共享错误只投影一次,设置页不显示技术诊断。可用本地产物未签名,真实升级链仍待 Release Owner 验收 | +| 2026-08-14 | AI Canvas 双向 Gateway 与确认任务恢复 | 本地功能验证通过;未执行真实账号付费生成 smoke | 源任务 `20260814-design-generation-runtime-7d3a`、源提交 `22378ef` | 3 files / 74 focused tests、156 files / 1687 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Sol review 通过;覆盖 WebSocket 命令关联、幂等传输回退、Quote 任务恢复、Conversation 切换与 ABA,生产 worker 故障仍需真实环境区分 | ## When To Add Evidence diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index 275cc63..e4ee4e6 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -12,6 +12,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | 2026-08-13 | 将 AI 绘画素材选择 quick reply 升级为结构化 action/purpose | 新增第三种素材输入用途、调整本地化文案或重构 Design Message 协议时 | 客户端/服务端集成 | Pending | 用 `select_image_asset` + `image_reference | video_first_frame` 等稳定字段替代精确中文字符串匹配,并覆盖延迟 Brief 快照兼容 | | 2026-08-13 | 完成图生图客户端与服务端生产整链 smoke | 相匹配客户端与服务端部署后 | 客户端/服务端集成 | Pending | 用当前 Workspace 已完成作品与本地上传各执行一次图生图,核对 Asset 所有权、Quote、任务创建、结果展示与下载;不以本地单测替代付费 Provider 验收 | | 2026-08-13 | 发布并验收 Makelore 稳定更新链 | 权威版本对齐且 Windows/macOS 正式产物已签名/公证后 | 客户端发布 | Pending | 将 updater-role 产物 inactive-first 发布到 Works Square,核对 manifest/签名/架构后激活,并从旧稳定版执行发现、下载、重启、安装与版本确认 smoke;源码错误文案不替代该验收 | +| 2026-08-14 | 验收 AI Canvas 双向 Gateway 与 Quote 任务恢复 | 匹配客户端与服务端部署后 | 客户端/服务端集成 | Pending | 用真实账号确认一次图片生成,核对连接期间命令/Run/设计事件共用 WebSocket、无 `/runs/{run_id}` 轮询、任务按 Quote 出现在 Workspace 列表;若无任务且返回 `agent_runtime_unavailable`,检查 Gateway Run/worker traceback 与关联日志 | ## Use diff --git a/README.md b/README.md index 9fe4ba0..9025da2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 -- AI 绘画:每个设计项目固定一个设计 Agent,并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation;图片/视频生成任务与资产属于 Workspace,切换或新建会话不会创建新项目,也不会隐藏项目任务。图片创作既支持文生图,也支持从当前项目已完成作品或本地上传中选择一张参考图继续生成;输入框支持直接上传参考图,上传后可在候选区点击或输入 `@` 选择,并将所选资产随消息提交;视频沿用同一单图选择器绑定首帧。每条 Conversation 复用服务端持久 Agent Gateway Session,任务进度通过实时流推送并在统一列表中展示,断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 +- AI 绘画:每个设计项目固定一个设计 Agent,并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation;图片/视频生成任务与资产属于 Workspace,切换或新建会话不会创建新项目,也不会隐藏项目任务。图片创作既支持文生图,也支持从当前项目已完成作品或本地上传中选择一张参考图继续生成;输入框支持直接上传参考图,上传后可在候选区点击或输入 `@` 选择,并将所选资产随消息提交;视频沿用同一单图选择器绑定首帧。每条 Conversation 复用服务端持久 Agent Gateway Session;已连接时 Agent 命令、流式回复和任务进度共用 WebSocket,断流时使用幂等 REST 提交与低频同步。确认结果会按 Quote 对账,即使 Run 在任务落库后异常结束,Workspace 任务仍会恢复到统一列表。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 - AI 绘画项目栏会在当前项目下保留会话历史,默认显示最近五条消息摘要和更新时间,更多会话可展开;新建或点击历史条目都在同一项目中切换并恢复完整对话。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 字体系统:Renderer UI 内嵌 Inter Variable 与 Source Han Sans SC,按字符范围统一中英文;日文与俄文保留语言感知的系统回退,代码、路径和日志使用独立等宽字体。 @@ -90,7 +90,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在系统提供受保护凭据存储时加密落盘;Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 - AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识和非敏感作品资料;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。 -- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、断点续传与事件契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 +- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 - AI 编程的 Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 11324c4..bd8a712 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -212,13 +212,27 @@ type QueuedTaskEvent = { type AgentRunEventWaiter = (run: ServerAgentRun) => void; +type AgentCommandWaiter = { + resolve(command: ServerAgentCommand): void; + reject(error: unknown): void; + timeout: ReturnType; +}; + +type AgentCommandChannel = { + socket: AgentWebSocket; + waiters: Map; +}; + const AGENT_WEBSOCKET_OPEN = 1; const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000; +const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000; const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000; const AGENT_RUN_MAX_POLL_INTERVAL_MS = 5_000; const AGENT_RUN_TIMEOUT_MS = 10 * 60_000; const DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS = 1_000; +class AgentCommandTransportError extends Error {} + function mapBrief(brief: ServerBrief): DesignBrief { const medium = brief.medium ?? null; if (medium !== null && medium !== 'image' && medium !== 'video') { @@ -693,7 +707,7 @@ function asErrorDetail(payload: unknown): ServerErrorDetail { return payload as ServerErrorDetail; } -function userFacingErrorMessage(code: string, fallback: string): string { +function userFacingErrorMessage(code: string): string { if (code === 'generation_task_not_created') { return '生成方案已确认,但任务创建失败,请刷新后重试'; } @@ -710,9 +724,12 @@ function userFacingErrorMessage(code: string, fallback: string): string { design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', design_runtime_unavailable: 'AI 设计服务暂时不可用', design_production_unavailable: '当前生成能力暂时不可用', + agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试', agent_command_invalid: '设计请求内容无效,请检查后重试', + design_agent_run_cancelled: '设计 Agent 请求已取消', + design_agent_run_failed: '设计 Agent 暂时不可用,请稍后重试', }; - return messages[code] ?? fallback; + return messages[code] ?? 'AI 设计请求失败,请稍后重试'; } function agentRunErrorStatus(code: string): number { @@ -732,7 +749,8 @@ function agentRunErrorStatus(code: string): number { } if (code === 'design_reasoner_unavailable' || code === 'design_runtime_unavailable' - || code === 'design_production_unavailable') { + || code === 'design_production_unavailable' + || code === 'agent_runtime_unavailable') { return 503; } return 502; @@ -754,6 +772,8 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { private readonly terminalAgentRuns = new Map(); private readonly agentRunEventWaiters = new Map>(); private readonly runStreamEndWaiters = new Map void>>(); + private readonly agentCommandChannels = new Map>(); + private nextAgentCommandRequestId = 0; private eventSessionsEnabled = true; constructor(options: WorksSquareDesignWorkspaceOptions = {}) { @@ -891,42 +911,144 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { const code = run.error?.code ?? ( run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' ); - const fallback = run.error?.message ?? ( - run.status === 'cancelled' - ? '设计 Agent 请求已取消' - : '设计 Agent 暂时不可用,请稍后重试' - ); throw new DesignWorkspaceModuleError( agentRunErrorStatus(code), code, - userFacingErrorMessage(code, fallback), + userFacingErrorMessage(code), ); } return this.getConversation(input.workspaceId, input.conversationId); } - private submitTurnCommand( + private async submitTurnCommand( sessionId: string, input: AgentDesignTurnSubmission, ): Promise { + const command = { + client_command_id: input.clientTurnId, + name: 'turn.submit', + input: { + expected_turn_revision: input.expectedTurnRevision, + message: input.message, + attachment_asset_ids: input.attachmentAssetIds, + action: input.action, + }, + }; + const channel = this.findAgentCommandChannel(sessionId); + if (channel) { + try { + return await this.submitTurnCommandOverWebSocket(channel, command); + } catch (error) { + if (!(error instanceof AgentCommandTransportError)) throw error; + } + } return this.requestJson( `/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, { method: 'POST', - body: JSON.stringify({ - client_command_id: input.clientTurnId, - name: 'turn.submit', - input: { - expected_turn_revision: input.expectedTurnRevision, - message: input.message, - attachment_asset_ids: input.attachmentAssetIds, - action: input.action, - }, - }), + body: JSON.stringify(command), }, ); } + private findAgentCommandChannel(sessionId: string): AgentCommandChannel | null { + const channels = this.agentCommandChannels.get(sessionId); + if (!channels) return null; + for (const channel of channels) { + if (channel.socket.readyState === AGENT_WEBSOCKET_OPEN) return channel; + } + return null; + } + + private submitTurnCommandOverWebSocket( + channel: AgentCommandChannel, + command: Record, + ): Promise { + const requestId = `design-command-${Date.now()}-${this.nextAgentCommandRequestId += 1}`; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + channel.waiters.delete(requestId); + reject(new AgentCommandTransportError('Agent WebSocket command acknowledgement timed out')); + }, AGENT_COMMAND_ACK_TIMEOUT_MS); + channel.waiters.set(requestId, { resolve, reject, timeout }); + try { + channel.socket.send(JSON.stringify({ + type: 'command.submit', + request_id: requestId, + command, + })); + } catch { + clearTimeout(timeout); + channel.waiters.delete(requestId); + reject(new AgentCommandTransportError('Agent WebSocket command send failed')); + } + }); + } + + private handleAgentCommandFrame(channel: AgentCommandChannel, data: unknown): void { + if (typeof data !== 'string') return; + let frame: Record; + try { + const parsed = JSON.parse(data) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + frame = parsed as Record; + } catch { + return; + } + const requestId = frame.request_id; + if (typeof requestId !== 'string') return; + const waiter = channel.waiters.get(requestId); + if (!waiter) return; + + if (frame.type === 'command.accepted') { + const command = frame.command; + if (!command || typeof command !== 'object' || Array.isArray(command) + || typeof (command as Record).run_id !== 'string') { + return; + } + clearTimeout(waiter.timeout); + channel.waiters.delete(requestId); + waiter.resolve(command as ServerAgentCommand); + return; + } + if (frame.type !== 'error') return; + const error = frame.error; + if (!error || typeof error !== 'object' || Array.isArray(error)) return; + const code = (error as Record).code; + const message = (error as Record).message; + if (typeof code !== 'string' || typeof message !== 'string') return; + clearTimeout(waiter.timeout); + channel.waiters.delete(requestId); + waiter.reject(new DesignWorkspaceModuleError( + agentRunErrorStatus(code), + code, + userFacingErrorMessage(code), + )); + } + + private registerAgentCommandChannel( + sessionId: string, + channel: AgentCommandChannel, + ): void { + const channels = this.agentCommandChannels.get(sessionId) ?? new Set(); + channels.add(channel); + this.agentCommandChannels.set(sessionId, channels); + } + + private unregisterAgentCommandChannel( + sessionId: string, + channel: AgentCommandChannel, + ): void { + const channels = this.agentCommandChannels.get(sessionId); + channels?.delete(channel); + if (channels?.size === 0) this.agentCommandChannels.delete(sessionId); + for (const waiter of channel.waiters.values()) { + clearTimeout(waiter.timeout); + waiter.reject(new AgentCommandTransportError('Agent WebSocket connection closed')); + } + channel.waiters.clear(); + } + private async waitForAgentRun(sessionId: string, runId: string): Promise { const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS; const streamedRun = await this.waitForAgentRunEvent(sessionId, runId, deadline); @@ -1091,6 +1213,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } const { socket } = connection; + const commandChannel: AgentCommandChannel = { socket, waiters: new Map() }; const queue = createTaskEventQueue(); let latestWorkspaceEventDelivery = Promise.resolve(); let didOpen = false; @@ -1119,7 +1242,10 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { if (heartbeat !== null) clearInterval(heartbeat); heartbeat = null; unregister(); - if (didOpen) this.unregisterRunEventStream(sessionId); + if (didOpen) { + this.unregisterAgentCommandChannel(sessionId, commandChannel); + this.unregisterRunEventStream(sessionId); + } try { await connection.dispose?.(); } catch { @@ -1163,6 +1289,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { if (ending) return; didOpen = true; this.registerRunEventStream(sessionId); + this.registerAgentCommandChannel(sessionId, commandChannel); heartbeat = setInterval(() => { if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return; try { @@ -1178,6 +1305,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }; socket.onmessage = ({ data }) => { if (ending) return; + this.handleAgentCommandFrame(commandChannel, data); const agentEvent = agentEventFromWebSocketFrame(data); const event = normalizeWorkspaceEvent( agentEvent, @@ -1254,13 +1382,10 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { const code = typeof detail.code === 'string' ? detail.code : 'DESIGN_WORKSPACE_REQUEST_FAILED'; - const fallback = typeof detail.message === 'string' - ? detail.message - : `AI 设计请求失败(${response.status})`; throw new DesignWorkspaceModuleError( response.status, code, - userFacingErrorMessage(code, fallback), + userFacingErrorMessage(code), ); } return payload as T; diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index 07044f4..8c7e8d0 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -484,6 +484,22 @@ export const useImageWorkspaceStore = create((set, get) => return message; }; + const refreshWorkspaceTasks = async ( + workspaceId: string, + ): Promise => { + const requestedViewRevision = get().workspace?.workspaceId === workspaceId + ? get().workspace?.viewRevision ?? 0 + : 0; + const tasks = await fetchImageWorkspaceTasks(workspaceId); + const currentWorkspace = get().workspace; + if (get().activeWorkspaceId === workspaceId + && currentWorkspace?.workspaceId === workspaceId + && currentWorkspace.viewRevision === requestedViewRevision) { + set((state) => ({ tasks: mergeTasks(state.tasks, tasks) })); + } + return tasks; + }; + const applyWorkspace = (workspace: DesignWorkspace): DesignWorkspace => { set((state) => { return { @@ -810,18 +826,8 @@ export const useImageWorkspaceStore = create((set, get) => set({ tasks: [] }); return []; } - const requestedViewRevision = get().workspace?.workspaceId === workspaceId - ? get().workspace?.viewRevision ?? 0 - : 0; try { - const tasks = await fetchImageWorkspaceTasks(workspaceId); - const currentWorkspace = get().workspace; - if (get().activeWorkspaceId === workspaceId - && currentWorkspace?.workspaceId === workspaceId - && currentWorkspace.viewRevision === requestedViewRevision) { - set((state) => ({ tasks: mergeTasks(state.tasks, tasks) })); - } - return tasks; + return await refreshWorkspaceTasks(workspaceId); } catch (error) { set({ error: handleRequestError(error) }); throw error; @@ -888,6 +894,17 @@ export const useImageWorkspaceStore = create((set, get) => const conversation = get().conversation; if (!workspace || !conversation) throw new Error('请先选择设计会话'); const clientTurnId = createImageWorkspaceTurnId(); + const requestWorkspaceLoadGeneration = workspaceLoadGeneration; + const requestConversationSelectionGeneration = conversationSelectionGeneration; + const isActiveConfirmationWorkspace = (): boolean => ( + get().activeWorkspaceId === workspace.workspaceId + ); + const isCurrentConfirmationConversation = (): boolean => ( + isActiveConfirmationWorkspace() + && requestWorkspaceLoadGeneration === workspaceLoadGeneration + && requestConversationSelectionGeneration === conversationSelectionGeneration + && get().activeConversationId === conversation.conversationId + ); try { set({ pendingTurn: createPendingTurn(conversation, clientTurnId, '确认生成'), @@ -900,39 +917,65 @@ export const useImageWorkspaceStore = create((set, get) => quoteId, clientTurnId, ); - if (get().activeWorkspaceId === workspace.workspaceId) { - if (get().activeConversationId === conversation.conversationId) { - applyConversation(updated); - } - let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId); - for ( - let attempt = 0; - !confirmedTaskVisible && attempt < CONFIRMED_TASK_RECONCILE_ATTEMPTS; - attempt += 1 - ) { - if (get().activeWorkspaceId !== workspace.workspaceId) return updated; - const refreshed = await get().refreshTasks(); - if (get().activeWorkspaceId !== workspace.workspaceId) return updated; - confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId) - || get().tasks.some((task) => task.quoteId === quoteId); - if (!confirmedTaskVisible && attempt + 1 < CONFIRMED_TASK_RECONCILE_ATTEMPTS) { - await new Promise((resolve) => { - setTimeout(resolve, CONFIRMED_TASK_RECONCILE_INTERVAL_MS); - }); - } - } - if (!confirmedTaskVisible) { - throw new ImageWorkspaceApiError( - 502, - 'generation_task_not_visible', - '生成已确认,但任务列表同步超时。请刷新项目查看,不要重复确认。', - ); + if (isCurrentConfirmationConversation()) applyConversation(updated); + if (!isActiveConfirmationWorkspace()) return updated; + let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId); + for ( + let attempt = 0; + !confirmedTaskVisible && attempt < CONFIRMED_TASK_RECONCILE_ATTEMPTS; + attempt += 1 + ) { + if (!isActiveConfirmationWorkspace()) return updated; + const refreshed = await refreshWorkspaceTasks(workspace.workspaceId); + if (!isActiveConfirmationWorkspace()) return updated; + confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId) + || get().tasks.some((task) => task.quoteId === quoteId); + if (!confirmedTaskVisible && attempt + 1 < CONFIRMED_TASK_RECONCILE_ATTEMPTS) { + await new Promise((resolve) => { + setTimeout(resolve, CONFIRMED_TASK_RECONCILE_INTERVAL_MS); + }); } } + if (!confirmedTaskVisible) { + throw new ImageWorkspaceApiError( + 502, + 'generation_task_not_visible', + '生成已确认,但任务列表同步超时。请刷新项目查看,不要重复确认。', + ); + } return updated; } catch (error) { - if (get().activeWorkspaceId !== workspace.workspaceId - || get().activeConversationId !== conversation.conversationId) throw error; + if (!isActiveConfirmationWorkspace()) throw error; + const taskVisibilityAlreadyReconciled = error instanceof ImageWorkspaceApiError + && error.code === 'generation_task_not_visible'; + let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId); + if (!taskVisibilityAlreadyReconciled && !confirmedTaskVisible) { + const refreshed = await refreshWorkspaceTasks(workspace.workspaceId).catch(() => []); + if (!isActiveConfirmationWorkspace()) throw error; + confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId) + || get().tasks.some((task) => task.quoteId === quoteId); + } + if (confirmedTaskVisible) { + if (!isCurrentConfirmationConversation()) return conversation; + const refreshedConversation = await fetchImageWorkspaceConversation( + workspace.workspaceId, + conversation.conversationId, + ).catch(() => null); + if (!isCurrentConfirmationConversation()) return conversation; + const recoveredConversation = refreshedConversation + ? applyConversation(refreshedConversation) + : get().conversation; + if (recoveredConversation?.conversationId === conversation.conversationId) { + set((state) => ({ + error: null, + pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId + ? null + : state.pendingTurn, + })); + return recoveredConversation; + } + } + if (!isCurrentConfirmationConversation()) throw error; const errorMessage = handleRequestError(error); set((state) => ({ error: errorMessage, diff --git a/tests/unit/ai-hardware-page.test.tsx b/tests/unit/ai-hardware-page.test.tsx index 57ba4ac..72a8249 100644 --- a/tests/unit/ai-hardware-page.test.tsx +++ b/tests/unit/ai-hardware-page.test.tsx @@ -58,6 +58,13 @@ function deferred() { return { promise, resolve }; } +async function openConfigurationEditor(): Promise { + const editButton = await screen.findByRole('button', { name: '编辑配置' }); + await waitFor(() => expect(editButton).toBeEnabled()); + fireEvent.click(editButton); + await screen.findByRole('heading', { name: '编辑智能体配置' }); +} + describe('AI hardware page', () => { beforeEach(() => { api.getAiHardwareOverview.mockResolvedValue(activeOverview); @@ -139,7 +146,7 @@ describe('AI hardware page', () => { it('sends cleared nullable configuration fields through clear_fields and never sends null', async () => { render(); - fireEvent.click(await screen.findByRole('button', { name: '编辑配置' })); + await openConfigurationEditor(); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的助手' } }); fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '' } }); fireEvent.change(screen.getByLabelText('TTS 语音 ID'), { target: { value: '' } }); @@ -229,7 +236,7 @@ describe('AI hardware page', () => { revision: 4, }); render(); - fireEvent.click(await screen.findByRole('button', { name: '编辑配置' })); + await openConfigurationEditor(); fireEvent.click(screen.getByText('高级设置')); const values: Record = { @@ -336,7 +343,7 @@ describe('AI hardware page', () => { revision: 4, }); render(); - fireEvent.click(await screen.findByRole('button', { name: '编辑配置' })); + await openConfigurationEditor(); expect(screen.getByLabelText('聊天记录')).toHaveValue(''); fireEvent.click(screen.getByRole('button', { name: '保存' })); @@ -381,7 +388,7 @@ describe('AI hardware page', () => { status: 409, code: 'ai_hardware_revision_conflict', message: 'server details', })); render(); - fireEvent.click(await screen.findByRole('button', { name: '编辑配置' })); + await openConfigurationEditor(); fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '保留的本地提示' } }); fireEvent.click(screen.getByRole('button', { name: '保存' })); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 1a1ddfb..ab2dabf 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; +import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import type { DesignAssistantDeltaEvent, DesignConversation, @@ -302,6 +303,196 @@ describe('AI design task event store', () => { expect(useImageWorkspaceStore.getState().tasks).toEqual([task]); }); + it('recovers a committed generation task when the Agent Run fails afterward', async () => { + const source = new MockEventSource(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceTasksMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([task]); + fetchImageWorkspaceConversationMock + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one')) + .mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-one')); + confirmImageWorkspaceGenerationMock.mockRejectedValueOnce( + new ImageWorkspaceApiError( + 503, + 'agent_runtime_unavailable', + 'AI 设计服务暂时不可用,请稍后重试', + ), + ); + + await useImageWorkspaceStore.getState().load(); + await expect(useImageWorkspaceStore.getState().confirmGeneration('quote-one')) + .resolves.toMatchObject({ turnRevision: 2 }); + + expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce(); + expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + error: null, + pendingTurn: null, + conversation: { turnRevision: 2 }, + tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }], + }); + }); + + it('keeps reconciling Workspace tasks after switching Conversations during confirmation', async () => { + const first = new MockEventSource(); + const second = new MockEventSource(); + const confirmation = deferred(); + const project = workspace(); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 1, + 'conversation-two', + ); + openImageWorkspaceTaskEventsMock + .mockResolvedValueOnce(first as unknown as EventSource) + .mockResolvedValueOnce(second as unknown as EventSource); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + fetchImageWorkspaceTasksMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([task]); + fetchImageWorkspaceConversationMock + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one')) + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-two')); + confirmImageWorkspaceGenerationMock.mockReturnValueOnce(confirmation.promise); + + await useImageWorkspaceStore.getState().load(); + const confirming = useImageWorkspaceStore.getState().confirmGeneration('quote-one'); + await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce()); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + confirmation.resolve(conversation('workspace-one', 2, 'conversation-one')); + await confirming; + + expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-one', + activeConversationId: 'conversation-two', + conversation: { conversationId: 'conversation-two', turnRevision: 1 }, + pendingTurn: null, + error: null, + tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }], + }); + }); + + it('keeps the newer Conversation error when stale confirmation task refresh fails', async () => { + const first = new MockEventSource(); + const second = new MockEventSource(); + const confirmation = deferred(); + const project = workspace(); + const { messages: _messages, ...secondSummary } = conversation( + 'workspace-one', + 1, + 'conversation-two', + ); + const refreshError = new Error('任务刷新失败'); + openImageWorkspaceTaskEventsMock + .mockResolvedValueOnce(first as unknown as EventSource) + .mockResolvedValueOnce(second as unknown as EventSource); + fetchImageWorkspaceProjectMock.mockResolvedValue({ + ...project, + conversationCount: 2, + conversations: [...project.conversations, secondSummary], + }); + fetchImageWorkspaceTasksMock + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(refreshError) + .mockResolvedValueOnce([task]); + fetchImageWorkspaceConversationMock + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one')) + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-two')); + confirmImageWorkspaceGenerationMock.mockReturnValueOnce(confirmation.promise); + + await useImageWorkspaceStore.getState().load(); + const confirming = useImageWorkspaceStore.getState().confirmGeneration('quote-one'); + await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce()); + await useImageWorkspaceStore.getState().selectConversation('conversation-two'); + useImageWorkspaceStore.setState({ error: '新会话错误' }); + confirmation.resolve(conversation('workspace-one', 2, 'conversation-one')); + await confirming; + + expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeConversationId: 'conversation-two', + conversation: { conversationId: 'conversation-two', turnRevision: 1 }, + error: '新会话错误', + tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }], + }); + }); + + it('still reports errors from an explicit task refresh', async () => { + const refreshError = new Error('主动刷新任务失败'); + fetchImageWorkspaceTasksMock + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(refreshError); + + await useImageWorkspaceStore.getState().load(); + await expect(useImageWorkspaceStore.getState().refreshTasks()).rejects.toBe(refreshError); + + expect(useImageWorkspaceStore.getState().error).toBe('主动刷新任务失败'); + }); + + it('does not let a failed ABA confirmation overwrite a newer turn in the same Workspace', async () => { + const source = new MockEventSource(); + const latestResponse = deferred(); + const runtimeError = new ImageWorkspaceApiError( + 503, + 'agent_runtime_unavailable', + 'AI 设计服务暂时不可用,请稍后重试', + ); + let rejectConfirmation!: (error: unknown) => void; + const staleConfirmationResponse = new Promise((_resolve, reject) => { + rejectConfirmation = reject; + }); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two'])); + fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => ( + Promise.resolve(workspace(workspaceId)) + )); + fetchImageWorkspaceTasksMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([task]); + fetchImageWorkspaceConversationMock + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one')) + .mockResolvedValueOnce(conversation('workspace-two', 1, 'conversation-two')) + .mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one')) + .mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-one')); + confirmImageWorkspaceGenerationMock.mockReturnValueOnce(staleConfirmationResponse); + sendImageWorkspaceMessageMock.mockReturnValueOnce(latestResponse.promise); + + await useImageWorkspaceStore.getState().load(); + const staleOutcome = useImageWorkspaceStore.getState().confirmGeneration('quote-one').then( + (value) => ({ value, error: null }), + (error: unknown) => ({ value: null, error }), + ); + await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce()); + await useImageWorkspaceStore.getState().selectProject('workspace-two'); + await useImageWorkspaceStore.getState().selectProject('workspace-one'); + const latestTurn = useImageWorkspaceStore.getState().sendMessage('继续调整最新方案'); + + rejectConfirmation(runtimeError); + + await expect(staleOutcome).resolves.toMatchObject({ + value: { conversationId: 'conversation-one', turnRevision: 1 }, + error: null, + }); + expect(fetchImageWorkspaceConversationMock).toHaveBeenCalledTimes(3); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-one', + activeConversationId: 'conversation-one', + conversation: { turnRevision: 1 }, + pendingTurn: { userText: '继续调整最新方案' }, + error: null, + }); + + latestResponse.resolve(conversation('workspace-one', 2, 'conversation-one')); + await latestTurn; + }); + it('does not silently complete confirmation when the quoted task stays missing', async () => { const source = new MockEventSource(); openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index 3bf00a4..762c3f0 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -75,6 +75,7 @@ type MockSocketScript = { open?: boolean; closeCode?: number; closeReason?: string; + onSend?: (socket: MockAgentWebSocket, data: string) => void; }; class MockAgentWebSocket { @@ -94,6 +95,7 @@ class MockAgentWebSocket { send(data: string): void { this.sent.push(data); + this.script.onSend?.(this, data); } emitFrame(frame: unknown): void { @@ -136,6 +138,29 @@ function scriptedSockets(scripts: MockSocketScript[]) { return { sockets, webSocketFactory }; } +function acceptCommand( + socket: MockAgentWebSocket, + rawFrame: string, + runId: string, + events: unknown[], +): void { + const frame = JSON.parse(rawFrame) as Record; + if (frame.type !== 'command.submit') return; + queueMicrotask(() => { + socket.emitFrame({ + type: 'command.accepted', + request_id: frame.request_id, + command: { + command_id: `command-${runId}`, + run_id: runId, + status: 'queued', + error: null, + }, + }); + for (const event of events) socket.emitFrame(event); + }); +} + describe('Works Square AI design adapter', () => { beforeEach(() => { getTokenMock.mockReset(); @@ -364,22 +389,10 @@ describe('Works Square AI design adapter', () => { }); it('completes a design turn from the connected WebSocket without polling the run endpoint', async () => { - const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); - const fetchMock = vi.fn(async (input) => { - const url = String(input); - if (url.endsWith('/conversations/conversation-one')) { - return jsonResponse({ ...serverConversation, agent_session_id: 'session-live' }); - } - if (url.endsWith('/stream-tickets')) { - return jsonResponse({ - ticket: 'ticket-live', - transport: 'websocket', - stream_url: '/api/agents/sessions/session-live/ws?ticket=ticket-live', - expires_at: '2026-08-03T03:00:00Z', - }); - } - if (url.endsWith('/api/agents/sessions/session-live/commands')) { - queueMicrotask(() => sockets[0]?.emitFrame({ + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + acceptCommand(socket, rawFrame, 'run-live', [{ type: 'event', event: { session_id: 'session-live', @@ -394,12 +407,21 @@ describe('Works Square AI design adapter', () => { occurred_at: '2026-08-03T02:00:05Z', payload: { status: 'succeeded' }, }, - })); + }]); + }, + }]); + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-live' }); + } + if (url.endsWith('/stream-tickets')) { return jsonResponse({ - command_id: 'command-live', - run_id: 'run-live', - status: 'accepted', - }, 202); + ticket: 'ticket-live', + transport: 'websocket', + stream_url: '/api/agents/sessions/session-live/ws?ticket=ticket-live', + expires_at: '2026-08-03T03:00:00Z', + }); } if (url.endsWith('/conversations/conversation-one')) { return jsonResponse(serverConversation); @@ -427,13 +449,264 @@ describe('Works Square AI design adapter', () => { } }); - it('does not let terminal Run completion overtake streamed design events', async () => { + it('maps a matching top-level WebSocket command error without using the HTTP fallback', async () => { + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + const frame = JSON.parse(rawFrame) as Record; + expect(frame).toMatchObject({ + type: 'command.submit', + command: { + client_command_id: 'turn-command-error', + name: 'turn.submit', + }, + }); + expect(frame.request_id).toEqual(expect.any(String)); + queueMicrotask(() => socket.emitFrame({ + type: 'error', + request_id: frame.request_id, + error: { + code: 'agent_runtime_unavailable', + message: 'Agent Runtime is temporarily unavailable', + retryable: true, + }, + })); + }, + }]); + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-command-error' }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-command-error/ws?ticket=ticket-command-error', + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + }); + const subscription = await adapter.openWorkspaceEvents({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + }); + + try { + await expect(adapter.submitMessage({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + clientTurnId: 'turn-command-error', + expectedTurnRevision: 1, + message: '继续设计', + })).rejects.toMatchObject({ + status: 503, + code: 'agent_runtime_unavailable', + message: 'AI 设计服务暂时不可用,请稍后重试', + }); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/commands'))).toBe(false); + } finally { + subscription.close(); + } + }); + + it('redacts an unknown top-level WebSocket command error without using the HTTP fallback', async () => { + const privateDetail = 'private upstream host and stack trace'; + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + const frame = JSON.parse(rawFrame) as Record; + queueMicrotask(() => socket.emitFrame({ + type: 'error', + request_id: frame.request_id, + error: { + code: 'gateway_private_failure', + message: privateDetail, + retryable: false, + }, + })); + }, + }]); + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-private-error' }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-private-error/ws?ticket=ticket-private-error', + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + }); + const subscription = await adapter.openWorkspaceEvents({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + }); + + try { + const error = await adapter.submitMessage({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + clientTurnId: 'turn-private-error', + expectedTurnRevision: 1, + message: '继续设计', + }).then( + () => null, + (reason: unknown) => reason, + ); + + expect(error).toMatchObject({ + status: 502, + code: 'gateway_private_failure', + message: 'AI 设计请求失败,请稍后重试', + }); + expect((error as Error).message).not.toContain(privateDetail); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/commands'))).toBe(false); + } finally { + subscription.close(); + } + }); + + it('falls back to the idempotent HTTP command when the WebSocket acknowledgement times out', async () => { + vi.useFakeTimers(); const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); + let conversationReads = 0; + const fetchMock = vi.fn(async (input, init) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + conversationReads += 1; + return jsonResponse({ + ...serverConversation, + agent_session_id: 'session-ack-timeout', + turn_revision: conversationReads === 1 ? 1 : 2, + }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-ack-timeout/ws?ticket=ticket-timeout', + }); + } + if (url.endsWith('/api/agents/sessions/session-ack-timeout/commands')) { + expect(JSON.parse(String(init?.body))).toMatchObject({ + client_command_id: 'turn-ack-timeout', + name: 'turn.submit', + }); + queueMicrotask(() => sockets[0]?.emitFrame({ + type: 'event', + event: { + session_id: 'session-ack-timeout', + sequence: 6, + runtime: 'design', + type: 'run.completed', + run_id: 'run-ack-timeout', + schema_version: 1, + payload: { status: 'succeeded' }, + }, + })); + return jsonResponse({ run_id: 'run-ack-timeout', status: 'queued', error: null }, 202); + } + throw new Error(`Unexpected request: ${url}`); + }); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + }); + const subscriptionPromise = adapter.openWorkspaceEvents({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + }); + await vi.advanceTimersByTimeAsync(0); + const subscription = await subscriptionPromise; + + try { + const turn = adapter.submitMessage({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + clientTurnId: 'turn-ack-timeout', + expectedTurnRevision: 1, + message: '继续设计', + }); + await vi.advanceTimersByTimeAsync(5_000); + + await expect(turn).resolves.toMatchObject({ turnRevision: 2 }); + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/commands'))) + .toHaveLength(1); + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))) + .toHaveLength(0); + } finally { + subscription.close(); + vi.useRealTimers(); + } + }); + + it('does not let terminal Run completion overtake streamed design events', async () => { const streamedConversation = { ...serverConversation, agent_session_id: 'session-ordered', turn_revision: 2, }; + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + acceptCommand(socket, rawFrame, 'run-ordered', [{ + type: 'event', + event: { + session_id: 'session-ordered', + sequence: 4, + runtime: 'design', + type: 'design.assistant.delta', + run_id: 'run-ordered', + schema_version: 1, + payload: { + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + client_turn_id: 'turn-ordered', + turn_revision: 2, + chunk_index: 0, + delta: '??????', + }, + }, + }, { + type: 'event', + event: { + session_id: 'session-ordered', + sequence: 5, + runtime: 'design', + type: 'design.conversation.updated', + run_id: 'run-ordered', + schema_version: 1, + payload: { + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + workspace_view_revision: 3, + conversation: streamedConversation, + generation_tasks: [], + }, + }, + }, { + type: 'event', + event: { + session_id: 'session-ordered', + sequence: 6, + runtime: 'design', + type: 'run.completed', + run_id: 'run-ordered', + schema_version: 1, + payload: { status: 'succeeded' }, + }, + }]); + }, + }]); const fetchMock = vi.fn(async (input) => { const url = String(input); if (url.endsWith('/conversations/conversation-one')) { @@ -444,60 +717,6 @@ describe('Works Square AI design adapter', () => { stream_url: '/api/agents/sessions/session-ordered/ws?ticket=ticket-ordered', }); } - if (url.endsWith('/api/agents/sessions/session-ordered/commands')) { - queueMicrotask(() => { - sockets[0]?.emitFrame({ - type: 'event', - event: { - session_id: 'session-ordered', - sequence: 4, - runtime: 'design', - type: 'design.assistant.delta', - run_id: 'run-ordered', - schema_version: 1, - payload: { - workspace_id: 'workspace-one', - conversation_id: 'conversation-one', - client_turn_id: 'turn-ordered', - turn_revision: 2, - chunk_index: 0, - delta: '??????', - }, - }, - }); - sockets[0]?.emitFrame({ - type: 'event', - event: { - session_id: 'session-ordered', - sequence: 5, - runtime: 'design', - type: 'design.conversation.updated', - run_id: 'run-ordered', - schema_version: 1, - payload: { - workspace_id: 'workspace-one', - conversation_id: 'conversation-one', - workspace_view_revision: 3, - conversation: streamedConversation, - generation_tasks: [], - }, - }, - }); - sockets[0]?.emitFrame({ - type: 'event', - event: { - session_id: 'session-ordered', - sequence: 6, - runtime: 'design', - type: 'run.completed', - run_id: 'run-ordered', - schema_version: 1, - payload: { status: 'succeeded' }, - }, - }); - }); - return jsonResponse({ run_id: 'run-ordered', status: 'queued', error: null }, 202); - } throw new Error(`Unexpected request: ${url}`); }); const adapter = new WorksSquareDesignWorkspace({ @@ -554,12 +773,45 @@ describe('Works Square AI design adapter', () => { it('bounds the delivery barrier when an opened stream has no consumer', async () => { vi.useFakeTimers(); - const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); const streamedConversation = { ...serverConversation, agent_session_id: 'session-bounded', turn_revision: 2, }; + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + acceptCommand(socket, rawFrame, 'run-bounded', [{ + type: 'event', + event: { + session_id: 'session-bounded', + sequence: 5, + runtime: 'design', + type: 'design.conversation.updated', + run_id: 'run-bounded', + schema_version: 1, + payload: { + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + workspace_view_revision: 3, + conversation: streamedConversation, + generation_tasks: [], + }, + }, + }, { + type: 'event', + event: { + session_id: 'session-bounded', + sequence: 6, + runtime: 'design', + type: 'run.completed', + run_id: 'run-bounded', + schema_version: 1, + payload: { status: 'succeeded' }, + }, + }]); + }, + }]); const fetchMock = vi.fn(async (input) => { const url = String(input); if (url.endsWith('/conversations/conversation-one')) { @@ -570,41 +822,6 @@ describe('Works Square AI design adapter', () => { stream_url: '/api/agents/sessions/session-bounded/ws?ticket=ticket-bounded', }); } - if (url.endsWith('/api/agents/sessions/session-bounded/commands')) { - queueMicrotask(() => { - sockets[0]?.emitFrame({ - type: 'event', - event: { - session_id: 'session-bounded', - sequence: 5, - runtime: 'design', - type: 'design.conversation.updated', - run_id: 'run-bounded', - schema_version: 1, - payload: { - workspace_id: 'workspace-one', - conversation_id: 'conversation-one', - workspace_view_revision: 3, - conversation: streamedConversation, - generation_tasks: [], - }, - }, - }); - sockets[0]?.emitFrame({ - type: 'event', - event: { - session_id: 'session-bounded', - sequence: 6, - runtime: 'design', - type: 'run.completed', - run_id: 'run-bounded', - schema_version: 1, - payload: { status: 'succeeded' }, - }, - }); - }); - return jsonResponse({ run_id: 'run-bounded', status: 'queued', error: null }, 202); - } throw new Error(`Unexpected request: ${url}`); }); const adapter = new WorksSquareDesignWorkspace({ @@ -641,19 +858,10 @@ describe('Works Square AI design adapter', () => { }); it('maps a failed Run received from the connected WebSocket without polling', async () => { - const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); - const fetchMock = vi.fn(async (input) => { - const url = String(input); - if (url.endsWith('/conversations/conversation-one')) { - return jsonResponse({ ...serverConversation, agent_session_id: 'session-failed' }); - } - if (url.endsWith('/stream-tickets')) { - return jsonResponse({ - stream_url: '/api/agents/sessions/session-failed/ws?ticket=ticket-failed', - }); - } - if (url.endsWith('/api/agents/sessions/session-failed/commands')) { - queueMicrotask(() => sockets[0]?.emitFrame({ + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + acceptCommand(socket, rawFrame, 'run-failed', [{ type: 'event', event: { session_id: 'session-failed', @@ -670,8 +878,18 @@ describe('Works Square AI design adapter', () => { }, }, }, - })); - return jsonResponse({ run_id: 'run-failed', status: 'queued', error: null }, 202); + }]); + }, + }]); + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-failed' }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-failed/ws?ticket=ticket-failed', + }); } throw new Error(`Unexpected request: ${url}`); }); @@ -700,6 +918,188 @@ describe('Works Square AI design adapter', () => { } }); + it('submits a confirmed Quote through the connected WebSocket and receives its task', async () => { + const generationTask = { + task_id: 'task-confirm-live', + workspace_id: 'workspace-one', + conversation_id: 'conversation-one', + medium: 'image', + status: 'queued', + brief_version: 1, + brief_summary: '公益海报', + quote_id: 'quote-one', + quoted_design_points: 1, + failure_code: null, + result_assets: [], + created_at: '2026-08-14T05:00:00Z', + updated_at: '2026-08-14T05:00:00Z', + }; + const { sockets, webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + acceptCommand(socket, rawFrame, 'run-confirm-live', [{ + type: 'event', + event: { + session_id: 'session-confirm-live', + sequence: 5, + runtime: 'design', + type: 'design.generation_task.updated', + run_id: 'run-confirm-live', + schema_version: 1, + payload: { + workspace_id: 'workspace-one', + workspace_view_revision: 3, + generation_task: generationTask, + }, + }, + }, { + type: 'event', + event: { + session_id: 'session-confirm-live', + sequence: 6, + runtime: 'design', + type: 'run.completed', + run_id: 'run-confirm-live', + schema_version: 1, + payload: { status: 'succeeded' }, + }, + }]); + }, + }]); + let conversationReads = 0; + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + conversationReads += 1; + return jsonResponse({ + ...serverConversation, + agent_session_id: 'session-confirm-live', + turn_revision: conversationReads === 1 ? 1 : 2, + }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-confirm-live/ws?ticket=ticket-confirm-live', + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + }); + const subscription = await adapter.openWorkspaceEvents({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + }); + const received: DesignWorkspaceEvent[] = []; + const consume = (async () => { + for await (const event of subscription.events) { + received.push(event); + break; + } + })(); + + try { + await expect(adapter.confirmGeneration({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + clientTurnId: 'turn-confirm-live', + expectedTurnRevision: 1, + quoteId: 'quote-one', + })).resolves.toMatchObject({ turnRevision: 2 }); + await consume; + + expect(received).toMatchObject([{ + type: 'design.generation_task.updated', + generationTask: { + taskId: 'task-confirm-live', + quoteId: 'quote-one', + status: 'queued', + }, + }]); + expect(JSON.parse(sockets[0].sent[0])).toMatchObject({ + type: 'command.submit', + command: { + client_command_id: 'turn-confirm-live', + name: 'turn.submit', + input: { + action: { type: 'confirm_generation', quote_id: 'quote-one' }, + }, + }, + }); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/commands'))).toBe(false); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/runs/'))).toBe(false); + } finally { + subscription.close(); + } + }); + + it('maps a generic Agent Runtime failure from the WebSocket to a safe 503', async () => { + const { webSocketFactory } = scriptedSockets([{ + open: true, + onSend(socket, rawFrame) { + acceptCommand(socket, rawFrame, 'run-unavailable', [{ + type: 'event', + event: { + session_id: 'session-unavailable', + sequence: 6, + runtime: 'design', + type: 'run.failed', + run_id: 'run-unavailable', + schema_version: 1, + payload: { + error: { + code: 'agent_runtime_unavailable', + message: 'Agent Runtime is temporarily unavailable', + retryable: true, + }, + }, + }, + }]); + }, + }]); + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('/conversations/conversation-one')) { + return jsonResponse({ ...serverConversation, agent_session_id: 'session-unavailable' }); + } + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ + stream_url: '/api/agents/sessions/session-unavailable/ws?ticket=ticket-unavailable', + }); + } + throw new Error(`Unexpected request: ${url}`); + }); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + webSocketFactory, + }); + const subscription = await adapter.openWorkspaceEvents({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + }); + + try { + await expect(adapter.confirmGeneration({ + workspaceId: 'workspace-one', + conversationId: 'conversation-one', + clientTurnId: 'turn-unavailable', + expectedTurnRevision: 1, + quoteId: 'quote-one', + })).rejects.toMatchObject({ + status: 503, + code: 'agent_runtime_unavailable', + message: 'AI 设计服务暂时不可用,请稍后重试', + }); + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(0); + } finally { + subscription.close(); + } + }); + it('falls back to one Run request after the WebSocket disconnects', async () => { const { webSocketFactory } = scriptedSockets([{ open: true, closeCode: 1006 }]); const fetchMock = vi.fn()