From 22378efcee07e7fb80b651e65e3202f1a1dfea1d Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 14 Aug 2026 14:42:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20AI=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A1=AE=E8=AE=A4=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20260814-design-generation-runtime-7d3a.md | 62 +++ README.md | 4 +- .../image-workspace/works-square-workspace.ts | 157 +++++- src/stores/image-workspace.ts | 95 +++- tests/unit/image-workspace-store.test.ts | 134 +++++ .../works-square-design-workspace.test.ts | 527 +++++++++++++----- 6 files changed, 808 insertions(+), 171 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260814-design-generation-runtime-7d3a.md diff --git a/.project-docs/30-worklog/tasks/20260814-design-generation-runtime-7d3a.md b/.project-docs/30-worklog/tasks/20260814-design-generation-runtime-7d3a.md new file mode 100644 index 0000000..ac66392 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260814-design-generation-runtime-7d3a.md @@ -0,0 +1,62 @@ +# Task: 修复 AI 设计确认生成任务失败 + +## Identity + +- Task ID: 20260814-design-generation-runtime-7d3a +- Mode: Feature +- Branch: codex/20260814-design-generation-runtime-7d3a-design-generation-runtime +- Worktree: D:\Datas\OthersProjects\makelore-design-generation-runtime-7d3a +- Base commit: 88f9ee8708beeb1ab45ca741807f6cca0f075ac9 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose and fix the AI Canvas confirmation path where `confirm_generation` ends with `Agent Runtime is temporarily unavailable` and no workspace generation task appears. +- Keep the fix inside the Renderer API -> Main Host route -> persistent Agent Gateway Session boundary. +- Add focused regression coverage for the failing confirmation path and task/event projection affected by the fix. +- Do not change unrelated Agent prompts, project publishing, galleries, or server implementation. + +## Intent And Constraints + +- Preserve ADR-001 ownership: conversation state is scoped by Workspace + Conversation, while tasks and assets are Workspace-owned. +- Treat the screenshot as evidence of a runtime failure before task creation, not proof that the right-hand task list itself is broken. +- Verify the current client and server Gateway/WebSocket contracts before changing payloads or error handling. +- Prefer the smallest contract-compatible fix and retain Main ownership of backend access, credentials, sessions, and event streams. +- The repository `main` worktree is owned by integration task `20260813-sync-push-main-9c2f71`; this feature remains isolated and will be handed off as ready for integration. + +## Outcome + +- Confirmed that the client only consumed Run events over WebSocket while still submitting `turn.submit` through REST, and that it did not recognize the Gateway-level `agent_runtime_unavailable` code. +- Added Main-owned bidirectional Gateway command submission: an open Conversation WebSocket sends `command.submit`, correlates `command.accepted` by `request_id`, and continues to complete the Run from streamed events. +- Added a five-second transport-only fallback to the existing REST command endpoint using the same `client_command_id`; Gateway business errors are not retried through REST. +- Mapped `agent_runtime_unavailable` to a safe Chinese 503 response. +- Reconciled failed confirmations by immutable `quote_id`. If the Design service committed a Generation Task before the Gateway Run failed, the client refreshes Workspace tasks and the captured Conversation without repeating confirmation; if no task exists, the original error remains visible. +- Split confirmation reconciliation by ownership: Quote/task refresh remains Workspace-wide even after switching Conversations, while applying a Conversation or clearing its error/pending state requires matching Workspace-load and Conversation-selection generations. An old A -> B -> A response cannot overwrite the newer Conversation. +- Updated `README.md` to describe the bidirectional Gateway path and Quote-based task reconciliation. + +## Verification + +- Red loop before implementation: + - `corepack pnpm exec vitest run tests/unit/works-square-design-workspace.test.ts -t "submits a confirmed Quote|maps a generic Agent Runtime"` -> 2 deterministic failures: REST `/commands` was still used and `agent_runtime_unavailable` projected as 502/English. + - `corepack pnpm exec vitest run tests/unit/image-workspace-store.test.ts -t "recovers a committed generation task"` -> deterministic failure because the task list was not reconciled after a failed Run. + - `corepack pnpm exec vitest run tests/unit/image-workspace-store.test.ts -t "does not let a failed ABA confirmation"` -> deterministic failure before the generation guard because the stale confirmation returned success after A -> B -> A. + - `corepack pnpm exec vitest run tests/unit/image-workspace-store.test.ts -t "keeps reconciling Workspace tasks"` -> deterministic failure before splitting the guards because switching Conversation A -> B skipped the Workspace task refresh. +- `corepack pnpm exec vitest run tests/unit/works-square-design-workspace.test.ts tests/unit/image-workspace-store.test.ts tests/unit/image-canvas-page.test.tsx` -> 3 files, 74 tests passed. +- `corepack pnpm run typecheck` -> passed. +- `corepack pnpm run lint:check` -> passed with 0 errors and 6 pre-existing warnings outside the changed files. +- `corepack pnpm test` -> 156 files, 1687 tests passed. An earlier run executed concurrently with the production build and transiently missed an unrelated AI Hardware form label; its isolated rerun passed before the clean full-suite reruns. +- `corepack pnpm run build:vite` -> Renderer, Electron Main, and Preload production builds passed; existing chunk-size and mixed import warnings remain. +- Independent Sol reviewer -> PASS after two review/fix rounds; no blocking issue remains in WebSocket command correlation, idempotent fallback, Quote recovery, Conversation switching, or ABA handling. +- No shared Electron E2E fixture for AI Canvas confirmation was found under `e2e` or `tests`; no live authenticated production smoke was run. + +## Follow-ups + +- After integration, run one authenticated production confirmation smoke and verify the task appears by `quote_id` without `/runs/{run_id}` polling while the WebSocket remains connected. +- If production still returns `agent_runtime_unavailable` and no task exists after reconciliation, inspect the matching server Gateway Run/worker logs. The code is a generic safety boundary for Runtime resolve, lease, database, event validation, or unexpected production-tool failures; the client cannot create the protected task directly. +- Improve server-side correlation/traceback logging for the generic Runtime safety boundary; current logging records only the unexpected exception type, which is insufficient to distinguish the underlying production failure. + +## Promotion Candidates + +- Integration owner should update canonical AI Canvas architecture/current-state memory to record that connected Agent commands and events are bidirectional over the Conversation WebSocket, with idempotent REST fallback only for transport failure. +- Integration owner should record Quote-based confirmation reconciliation: Generation Tasks remain Workspace-owned and may need REST recovery when the task transaction commits before a terminal Gateway failure. 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..243db58 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') { @@ -710,6 +724,7 @@ 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: '设计请求内容无效,请检查后重试', }; return messages[code] ?? fallback; @@ -732,7 +747,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 +770,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 = {}) { @@ -905,28 +923,135 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { 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, message), + )); + } + + 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 +1216,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 +1245,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 +1292,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 +1308,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }; socket.onmessage = ({ data }) => { if (ending) return; + this.handleAgentCommandFrame(commandChannel, data); const agentEvent = agentEventFromWebSocketFrame(data); const event = normalizeWorkspaceEvent( agentEvent, diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index 07044f4..0bf041c 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -888,6 +888,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 +911,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 get().refreshTasks(); + 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 get().refreshTasks().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/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 1a1ddfb..b044c70 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,139 @@ 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('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..b00e53f 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,137 @@ describe('Works Square AI design adapter', () => { } }); - it('does not let terminal Run completion overtake streamed design events', async () => { + 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 +590,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 +646,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 +695,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 +731,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 +751,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 +791,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()