diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index a84baf3..1bf84c2 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -7,12 +7,13 @@ | 登录续期 | Renderer 活动信号 | Main Works Session | 连续 7 天未使用才清除会话 | | 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 四布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate | | 项目创建 | 新建项目对话框 | Host API → Main 项目初始化 | 创建时固定 `ProjectType`;小游戏/小程序原子生成受控模板,自定义只生成项目空间 | -| 一键提交 | `ProjectPublishAction` | Renderer capability → Host API → Main 本地 npm/Vite build → built snapshot preflight → source+built+contract 上传 | 只对小游戏/小程序开放;首次 create 原子写入文字资料但不上传封面,已有 draft/published 只提交版本并沿用云端资料/封面;状态竞态固定失败,不做无条件 metadata PATCH | +| 一键提交 | `ProjectPublishAction` | Renderer capability → Host API → Main 本地 npm/Vite build → built snapshot preflight → source+built+contract 上传 | 只对小游戏/小程序开放;首次 create 通过单一 multipart 合同原子提交文字资料与必选 PNG/JPEG/WebP 封面,创建失败或冲突不上传版本;已有 draft/published 只提交版本并沿用云端资料/封面,不做无条件 metadata PATCH | | 构建产物预检 | Main-owned built snapshot | 一次性 loopback origin → fresh Electron WebContents/CDP(桌面、移动) | 检查错误、白屏和外域;不调用 Playwright,检查与上传归档相同字节,但不产生可信 receipt | | 提交绑定 | 云端成功上传响应 | Main → submission binding v2 | 只持久化成功的 app/version/review/hash;落盘失败返回固定告警但不反转提交 | | 运营发布 | Works Square 审核与交付 | 公共 `play_url` | 客户端只消费服务端发布结果;真实合同校验 → OSS/CDN 生产链仍待整链验收 | | 可信发布门禁 | source+built+artifact contract | 服务端逐字节重算/合同校验 → 不可变 Release 固化 → 人工审核 | 服务端不运行项目 Vite;仍是不可绕过权威,未来 runtime 强门禁需可信 verifier | -| AI 编程首次发送 | ChatPanel 当前 Agent | Renderer Store → Host API → Main → OpenCode session / prompt | 新建 session 已知为空时直接提交 prompt,不在关键路径等待空历史;历史未知或普通历史会话仍按默认路径加载消息 | +| AI 编程 Session 执行提交 | ChatPanel 当前 Agent / Session | Renderer per-Session run state → deferred provider sync → Host API → Main manager FIFO → per-project Agent FIFO → runtime/config + Agent readiness → OpenCode message / command / summarize | 新建 session 已知为空时不等待空历史;不同 Session 没有 reply-duration 全局锁。provider/runtime 或 Agent 未应用时以 typed `409`、`promptSent:false` 在执行前终止;普通执行路径不自动重启、reload 或 dispose 共享 runtime | +| AI 编程启动确认 | Host POST、OpenCode HTTP polling 与 Session events | Renderer `{sessionId, runToken}` watchdog → `sessionRunStates[sessionId]` | Host 接受与 user transcript 不算 ACK;busy/retry、assistant、question、permission 或 typed terminal event 才确认/结束启动。10 秒无确认只终止该 Session 为 `SESSION_START_UNCONFIRMED`,清理其队列且不自动重放 | | AI 编程上下文压缩 | OpenCode compaction Part / `session.compacted` / `/compact` | Renderer Store → per-session transcript state → Chat mixed timeline | 手动请求先创建 immutable UI event;native identity 负责归并与回放去重;完成只更新压缩项,真实 idle 才结束 run 和释放队列 | | AI 编程模型代理错误 | OpenCode provider 请求 | Main Host AI proxy → Works 模型上游 | 配额耗尽保持独立终止态;只有明确的分组上游饱和才投影为 OpenCode 终止状态,通用限速仍保留 `429` | | 客户端更新检查 | 设置页 | Renderer update store → IPC → Main AppUpdater → 目标 feed | Main 记录并重抛原始错误;Renderer 只显示去重、脱敏的单条提示,稳定源缺包不伪装为最新版 | @@ -43,6 +44,8 @@ - 本地开发适配器将旧单会话 schema v2 原子迁移为带默认 Conversation 的 schema v3;打包应用不使用该本地适配器作为云端失败回退。 - 注销和退出会关闭本地事件流并清除本机 Conversation Session-id 缓存;服务端持久 Session 保留,下一次访问从 Conversation API 重新读取。 - AI 编程 Store 的 `sessionMessagesBySessionId` own-key 是加载状态契约:键缺失表示历史未知,存在且值为 `[]` 表示已知为空。只有后者可使用不读取历史的快速选择;普通历史会话选择继续刷新消息。 +- AI 编程 Store 的运行态、错误、startup deadline 和内部 pending queue 按 Session 持有;top-level error 只表示真实全局错误。任何不确定的远程失败只清理对应 Session 的旧队列,手动重试不得复活失败前已排队的 prompt。 +- Main 以 manager identity + runtime generation provenance 持有 runtime-config latch,并以 `{runtimeGeneration, desiredFingerprint, appliedFingerprint}` 持有项目 Agent readiness。只有 owned `fresh` generation 可确认 fingerprint 已应用;live `/agent` 只验证 id,attached/unknown generation 和同 id 内容更新均不能越过门禁。 - AI 编程压缩状态由 transcript 中的 `compactionsById` / `compactionOrder` 单一持有。运行中 hydration 保留 manual pending identity 并用 native Part 合并;completed 不得回退为 running。`session.compacted` 不是 run idle,不能据此释放 queued prompt;失败、中止或 runtime generation 变化只清理对应未完成事件。 - Main Host AI proxy 可为固定 OpenCode 重试契约做窄化的内部状态投影:配额耗尽投影为 `402`,明确上游分组饱和的 `429` 投影为终止 `400`,其他 `429` 原样保留;升级 OpenCode 时必须重新验证该契约。 - Main AppUpdater 持有 feed、原始异常、下载和安装状态;设置页只消费状态投影。一次 electron-updater `error` 事件覆盖的并发检查不会在 Renderer 重复发错,但独立的后续检查仍有自己的报告生命周期。 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 272f9d8..0ead19b 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -15,9 +15,12 @@ | `electron/services/project-packager.ts` | 受控项目扫描、静态 ZIP 生成和敏感/历史控制文件排除 | 只允许可发布 `ProjectType`,不提供 Compose 或手工 ZIP 路径 | | `electron/services/works-submission-binding.ts` | submission binding v2 持久化与旧 schema 迁移 | 旧中间态终止为 `legacy_retired`;文件名暂作安装兼容 | | `electron/api/works-play-url.ts` | 公共播放 URL 的共享安全校验 | 公共 `play_url` 必须同源 HTTPS 且精确匹配 App 路径 | -| `src/pages/Chat/OpencodeChatPanel.tsx` | AI 编程 Agent 选择、首次 session 创建、prompt 提交与消息/压缩混合时间线 | 新 session 仅在消息缓存 own-key 已知时使用不读取历史的快速选择;历史压缩项静态渲染,实时转换才进入 live region | +| `src/pages/Chat/OpencodeChatPanel.tsx` | AI 编程 Agent 选择、首次 session 创建、per-Session prompt 提交与消息/压缩混合时间线 | 新 session 仅在消息缓存 own-key 已知时使用不读取历史的快速选择;选中 Session 只投影自己的 run error,top-level error 仅用于真实全局错误 | | `src/lib/opencode-session-state.ts` | OpenCode 会话消息与上下文压缩时间线的规范化、hydration 和事件归并 | 压缩 UI identity 不变;native Part/event identity 用于回放去重,completed 状态不可降级 | -| `src/stores/opencode.ts` | OpenCode runtime、session、消息缓存、提交与压缩生命周期 | `sessionMessagesBySessionId` 键缺失表示未知,存在空数组表示已知为空;`session.compacted` 只完成压缩项,真实 idle 才释放 run 队列 | +| `src/stores/opencode.ts` | OpenCode runtime、session、消息缓存、per-Session 提交/启动确认与压缩生命周期 | 每个 Session 独立持有 run/error/queue;10 秒 run-token watchdog 不把 Host 接受或 user transcript 当 ACK,超时只终止对应 Session 且不自动重放;`session.compacted` 只完成压缩项,真实 idle 才释放 run 队列 | +| `electron/api/routes/opencode.ts` | Main-owned OpenCode execution acceptance、provider/runtime freshness 与项目 Agent 门禁 | message/command/summarize 共用 bounded manager FIFO;Agent-scoped execution 再进入 per-project FIFO,typed pending 必须发生在 runtime 调用前,普通执行路径不自动 restart/reload/dispose | +| `electron/opencode/project-agent-runtime.ts` | 项目 Agent desired/applied fingerprint、runtime generation provenance、live registry acceptance 与配置 mutation 串行化 | live `/agent` 只证明 id;只有 owned fresh generation 可应用内容 fingerprint,attached/unknown fail closed,用户修改的 retired Agent 文件不得被删除 | +| `electron/opencode/runtime-config-readiness.ts` | manager + generation scoped provider/runtime stale latch | timeout/partial persistence 保持 sticky uncertain 状态;只有成功显式 apply 后的合格 fresh generation 可解除,迟到 lease 不能改写 readiness | | `electron/api/routes/ai-proxy.ts` | Main-owned 模型代理、凭据边界与上游响应投影 | 仅对明确上游分组饱和做终止态兼容投影,通用限速保持 `429` | | `shared/opencode-error-details.ts` | OpenCode 上游饱和错误的窄化共享分类 | 不以通用 `rate_limit_exceeded` 单独判定饱和 | | `electron/main/updater.ts` | 目标 feed 解析、electron-updater 生命周期与原始诊断 | 缺少稳定 manifest 保持错误;事件/Promise 重复失败按检查实例去重 | @@ -46,7 +49,7 @@ ## Dependency Direction - Renderer UI → Renderer API contract → Main Host routes → Main services → Works Square;Renderer 不反向读取 Main 凭据、文件系统或归档。 -- AI 编程 Renderer → Host API → Main OpenCode routes/runtime;OpenCode provider 请求再经 Main AI proxy 访问模型上游,Renderer 不直接持有上游凭据或本地 runtime URL。 +- AI 编程 Renderer per-Session run state → Host API → Main manager-scoped acceptance → per-project Agent acceptance → OpenCode runtime;OpenCode provider 请求再经 Main AI proxy 访问模型上游。Renderer 不直接持有上游凭据或本地 runtime URL,Main 临界区只覆盖配置/请求 acceptance,不覆盖模型回复时长。 - Project configuration 决定产品分流;Main release builder 生成 source/built/contract,服务端独立重算和校验决定发布安全,本地 `ProjectType` 不是授权结论。 - Built artifact preflight 检查最终上传的同字节快照,但客户端可被绕过且不产生可信 receipt;服务端仍是合同、摘要和不可变 Release 安全权威。 - Robot Renderer → typed AI hardware API → Main Host route → Robot Hotspot Module → Windows/macOS Adapter。云端 Binding 仍由 Main 代理;热点选择/连接移入页面,但家庭 Wi-Fi 凭据输入仍只留在固件 Portal,系统 Wi-Fi 保留为兜底。 @@ -62,6 +65,8 @@ - `runtime_url` 是一个客户端版本的迁移回退;删除前必须确认服务端和存量数据稳定提供 `play_url`。 - `ai-proxy.ts` 的上游饱和状态投影依赖当前固定 OpenCode 的重试语义和窄化错误文案;升级 runtime 或调整上游错误格式时必须复核,不能把所有 `429` 统一终止。 - OpenCode 压缩时间线依赖 native compaction Part、Session run token 与 runtime generation 的关联;hydration 必须保持 completed 单调,不能用 `session.compacted` 提前结束 run 或释放 queued prompt。 +- OpenCode execution acceptance 同时涉及 provider persistence、runtime lifecycle、Agent config mutation 和 runtime HTTP。锁顺序必须保持 manager → project,所有等待与 HTTP 都使用同一 hard deadline/AbortSignal;timeout 后 lease 必须撤销,queued cancellation 不能让后续 mutation 绕过前驱。 +- OpenCode Agent hot reload 没有 authoritative whole-instance quiescence oracle。不得从 `/session/status` 推断 dispose/reload 安全,也不得以 live 同 id 代替内容 fingerprint;若需要即时热更新,必须先获得 upstream directory-scoped invalidation 或权威 quiescence seam。 - `electron/main/updater.ts` 的稳定源错误归一化必须保持窄化:只识别 Works Square 对应 manifest 的 404,不得吞掉其他 feed/网络/签名错误;Renderer 的脱敏边界不能取代 Main 原始日志。 - 多 Conversation 事件处理必须区分对话快照与 Workspace 任务更新;不得用任务时间戳推进 Conversation 流水位,也不得让旧会话的迟到流覆盖当前会话。 - Quote 编辑、重报价、确认和项目删除都跨 Renderer/Main/Works Square。异步结果必须核对当前 Workspace + Conversation;删除当前项目时必须先使旧选择和事件流失效,再加载剩余 Workspace。 @@ -74,4 +79,4 @@ ## Last Updated -2026-08-16 +2026-08-17 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index d349722..6ecc5a7 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 +- `6504073`: AI Programming now isolates prompt lifecycle, loading and errors by OpenCode Session. Main serializes only runtime/configuration acceptance, verifies project Agent content against an owned fresh runtime generation before execution, and returns typed terminal pending responses without automatically restarting, reloading or disposing the shared runtime. A run that receives no explicit busy/assistant/terminal acknowledgement within 10 seconds ends only that Session and is never replayed automatically. The application-side isolation is verified; real bundled OpenCode/provider two-Session execution concurrency is not claimed. - Project-cover source commit `145a6ce571d646325092d1e722282babea503954` from feature task `20260817-project-cover-upload-a6a98e56`, integrated by task `20260813-sync-push-main-9c2f71`. First submission now requires a bounded PNG/JPEG/WebP cover, shows preview/file/reselect feedback, and sends metadata plus cover through Main-owned `POST /api/projects/with-cover`; conflicts stop before version upload and existing draft/published projects remain version-only. The matching Works Square server source is `407c883` (local merge `0cedfc4`). No client package, production deployment, or real-account smoke occurred. - `3b37ac3` / `55e61b7`: macOS Robot hotspot discovery performs one bounded worker-thread rescan after an empty or SSID-redacted CoreWLAN result; persistent SSID redaction maps to the existing permission error instead of a misleading empty-device state, while firmware and the open `Xiaozhi-*` contract remain unchanged. - `f5d47c8` / `b6148a5`: AI Programming voice capture is available after an Agent is selected but before the lazy first OpenCode session exists; transcription fills the composer draft without creating an empty session, while runtime, loading, transcribing, busy, and recording guards remain unchanged. @@ -42,7 +43,7 @@ Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/ap AI 学习现在是已启用顶层模块。Renderer 浏览 Works 课程、提交单课生成需求、显示任务进度并打开已安装课程;材料上传经 Main IPC 严格投影,需求最多 4,000 字,文件最多 5 个、单个 50 MiB、总计 150 MiB。Main 持有 Works Token、生成/课程下载/Agent/ASR/课堂 runtime 访问和按认证身份派生的不透明账号分区;账号切换、注销或 epoch 变化会隐藏旧本地课程、关闭旧 player server 并丢弃迟到结果。课程归档最大 512 MiB,只允许同 Works origin、最多 5 跳且不携带资源 Bearer 的下载重定向,按服务端声明大小与 SHA-256 校验后原子安装并在播放前再次校验。安装期和 player server 只允许 MIME/扩展匹配的被动图片、音视频和字体;同源 HTML/SVG/XML/脚本/PDF 被拒绝,运行时响应带 nosniff/sandbox CSP/CORP/no-store。只有显式 `readClassroom` 会注册课程;Agent/runtime 先验证调用前既存 active registration,再用无副作用 resolver 核对本地 aggregate/module,不能靠自身请求注册。HTTP URL/cookie 由短效 nonce 保护;iframe bridge 校验精确 source/origin,只允许初始 player 文档,二次导航后永久关闭。课程 aggregate `contentHash` 是云端权益/进度身份,模块 hash 只作为受控上下文。打包必须提供由固定清单与 SHA-256 验证的 OpenMAIC production Stage 产物;当前客户端自动化不等同于真实 Works 课程服务、生产播放器产物或签名安装包验收。 -AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`。上下文压缩以每个 Session 的持久时间线事件呈现:自动与手动压缩使用不同文案,运行态原位弱化显示,完成后静态保留并可从历史恢复;`session.compacted` 只完成对应事件,只有真实 idle 才结束 run 和释放排队消息。 +AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。每个 Session 独立持有 run、loading、error 与内部队列;一个 Session 的回复或终止不会锁住、改写或重放另一个 Session。Host 接受或用户消息落库不算启动确认,只有明确 busy/retry、assistant、question、permission 或 typed terminal event 才确认/终结启动;独立的 10 秒 watchdog 到期时仅以 `SESSION_START_UNCONFIRMED` 终止该 Session。Main 以 runtime generation provenance、Agent 内容 fingerprint 和 live registry id 共同验证项目 Agent,provider/runtime 或 Agent 未应用时在执行前返回 typed `409` 且 `promptSent:false`。普通 message、command、summarize 和后台 provider sync 不自动重启、reload 或 dispose 共享 runtime;只有显式用户 apply/lifecycle 操作可以推进运行时刷新。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`。上下文压缩以每个 Session 的持久时间线事件呈现:自动与手动压缩使用不同文案,运行态原位弱化显示,完成后静态保留并可从历史恢复;`session.compacted` 只完成对应事件,只有真实 idle 才结束 run 和释放排队消息。 Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失败语义。正式稳定源缺少对应平台 manifest 时,设置页只显示一条简洁中文提示并允许重试,不把缺包误报为已是最新版,也不向普通界面暴露堆栈、URL、路径或错误码;签名产物发布和真实升级安装仍属于外部 Release Gate。 @@ -50,6 +51,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Recently Completed +- 2026-08-17: Integrated application-side multi-Session isolation for AI Programming. Session A may remain busy while Session B is independently accepted or terminally rejected; errors, startup deadlines and uncertain-failure cleanup stay Session-scoped. Main now fail-closes stale Agent/provider runtime state before execution, applies bounded manager/project FIFO acceptance with revocable timeouts, and never refreshes the shared runtime automatically from ordinary execution paths. Full unit, typecheck, lint, build, focused Electron E2E and independent Sol review passed; a real paid-provider/bundled-runtime concurrency smoke remains pending. - 2026-08-17: Replaced the temporary coverless-first-create fallback with a required PNG/JPEG/WebP picker, preview, file name, reselect action, Renderer/Main signature and size validation, and one Main-owned multipart metadata-plus-cover create request. Create conflicts fail before version upload; existing project metadata and covers remain unchanged. - 2026-08-17: Corrected macOS Robot hotspot discovery after a system-visible `Xiaozhi-*` report. CoreWLAN now gets one bounded retry when its first result is empty or all SSIDs are unavailable; a persistent non-empty redacted result becomes the existing safe permission state. Open-only filtering, firmware, Host/Renderer contracts, exact-current-SSID verification, and the system-Wi-Fi fallback are unchanged; signed-package physical smoke remains pending. - 2026-08-17: Created merge commit `4013edc` for the reviewed per-user module-entry policy source tip `3b799af`. Main exposes only four booleans from `/api/auth/me`; missing fields remain enabled, `design` maps to `painting`, disabled root/deep/alias routes stop before module initialization, Code provider startup waits for policy hydration, terminal `401` clears both session layers, and global settings remains reachable. @@ -109,6 +111,8 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 - 客户端对最终构建字节的 loopback 检查没有可信 receipt,且不复刻生产 opaque-origin;服务端必须独立重算 source/built/contract、校验不可变 Release,人工审核仍不可绕过。如未来需要不可绕过的 runtime gate,必须引入可信 verifier 并绑定精确构建产物。 - AI proxy 的明确饱和 `429 → 400` 是与当前 OpenCode 重试契约匹配的内部投影;升级 OpenCode 或上游饱和错误文案变化时必须复核。不得把通用 `429` 或仅含 `rate_limit_exceeded` 的响应误分类为终止饱和。 - AI 编程压缩事件必须按 Session、run token、runtime generation 与 native Part/event identity 关联;`session.compacted` 不等于 `session.idle`,历史 hydration 不得把 completed 降回 running,失败或中止必须清理本 run 的未完成事件。 +- AI 编程多 Session 隔离只证明应用提交、状态、错误、超时和队列互不污染,不证明 bundled OpenCode 或真实 provider 会并行执行两个模型 turn。任何产品或性能结论都必须保留上游可能串行、限流或拒绝的边界。 +- 项目 Agent 与 provider/runtime freshness 必须在 Main 的 bounded acceptance 临界区内验证。普通 message、command、summarize 和后台同步不得自动重启、reload 或 dispose 共享 runtime;同 id 不能证明同内容已经加载,attached/unknown runtime generation 必须 fail closed。 - AI 绘画单图选择器仍精确匹配 quick reply `从作品列表选择图片`,用途再从当前 Brief medium 推断;本地化或协议文案变化可能破坏入口。扩展更多素材输入前应升级为结构化 action/purpose。 - Updater 源码错误提示不能代替发布正式签名产物;稳定 feed 缺 manifest 必须保持失败,Renderer 不得展示原始堆栈、URL、路径或错误码。升级链只有在旧版本完成发现、下载、重启和安装 smoke 后才可视为生产就绪。 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 49fe7c0..b68bcbe 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-17, resume the existing Integration owner after the user's explicit takeover authorization to merge reviewed AI Programming multi-Session/runtime-readiness source `6504073` into local `main`, preserve existing project-cover work, promote only the implemented application-isolation and no-automatic-runtime-refresh facts, run merged-tree verification and independent review, and keep remote push outside this resumption. - On 2026-08-17, resume the existing Integration owner to merge the reviewed first-submission project-cover source from task `20260817-project-cover-upload-a6a98e56` into local `main` after its matching server contract is integrated, replace only the obsolete coverless-first-create limitation, preserve existing-project version-only behavior and Main-owned publishing boundaries, and keep remote push outside this resumption. - On 2026-08-17, resume the existing Integration owner to merge reviewed macOS Robot hotspot scan source `55e61b7` into local `main`, preserve the firmware/open-SSID/Main-owned boundaries, retain signed-package physical smoke as a release gate, and keep remote push outside this resumption. - On 2026-08-17, resume the existing Integration owner at the user's request to merge reviewed AI Programming fresh-Agent voice-input source `b6148a5` into local `main`, preserve lazy first-session creation and existing voice guards, run merged-tree verification and independent review, and keep remote push outside this resumption. @@ -59,6 +60,7 @@ - The 2026-08-17 takeover confirmation authorizes continuing this existing Integration owner while preserving its uncommitted task history. For module access, source-task records remain read-only; the policy is a client entry/initialization gate rather than API authorization, and release acceptance requires the matching Works migration/API, a newly packaged client, and real-account four-module smoke. - The fresh-Agent voice-input request authorizes a local `main` merge only. It does not authorize remote push, Canvas voice enablement, eager OpenCode runtime/session creation, or changes to the existing Renderer -> Host API -> Electron Main -> Works Square speech boundary. - The project-cover request authorizes a local `main` merge only. It does not authorize remote push, client packaging, production deployment, metadata editing for existing projects, or weakening the Renderer -> Host API -> Electron Main ownership boundary. +- The multi-Session request authorizes local `main` integration of application-side Session isolation and runtime/Agent acceptance. It does not authorize a remote push, paid-provider traffic, a claim that bundled OpenCode/provider executes two model turns simultaneously, or automatic shared-runtime restart/reload/dispose. Source task records remain on feature history only. ## Project Context Loaded @@ -106,6 +108,14 @@ Relevant understanding: Gate result: - Passed. +### 2026-08-17 AI Programming Multi-Session Runtime Integration Resume + +- Reused the existing Integration owner after the user explicitly authorized adoption of its existing changes. Registry status confirms task `20260813-sync-push-main-9c2f71`, mode `integration`, branch `main`, worktree `D:\Datas\OthersProjects\makelore`, and the repository integration lock. +- Verified feature task `20260817-multichat-runtime-fix-f3a91c` is `ready_for_integration`, its worktree is clean, source commit `65040730ec229436b27e0e1a34ca16599b534996` is present, and its seventh independent Sol review returned Standards/Spec `PASS` after full unit, typecheck, lint, build and focused Electron E2E validation. +- Read the source outcome, follow-ups and promotion candidate against current AI Programming first-send, compaction and Main-owned runtime boundaries. No accepted ADR or peer source requires a reply-duration global lock, automatic runtime disposal, or same-id Agent freshness; the source strengthens existing Session isolation without changing product direction. +- Started a normal `--no-ff --no-commit` merge of `6504073` onto clean local `main` `b734867`; Git reported no textual conflicts. The source task record remains reachable on its source branch and is excluded from the integrated `main` tree. +- Gate result: Passed for local no-ff merge, semantic canonical promotion, proportionate merged-tree verification and independent final review. Remote push and real provider/runtime concurrency smoke remain outside this resumption. + ### 2026-08-17 Fresh-Agent Voice Input Integration Resume - Reused the existing Integration owner because it still exclusively owns clean local `main` at `9fd9a7761d3872f25e853ac1b059e55251c6ae8f`; `task_context.py touch` refreshed the reservation and registry doctor passed. @@ -216,6 +226,13 @@ Gate result: ## Plan +### 2026-08-17 AI Programming Multi-Session Runtime Integration Plan + +1. Merge reviewed source commit `6504073` into local `main` with a normal no-ff merge while preserving the current project-cover history and excluding the source-owned task record. +2. Promote the implemented per-Session startup/terminalization, Main runtime/Agent readiness, no-auto-replay and no-automatic-shared-runtime-refresh boundaries into current state, architecture, evidence and commitments without claiming upstream provider concurrency. +3. Run the focused AI Programming/Main/Renderer regression selection, full unit suite, typecheck, lint, Vite production build, focused Electron E2E, project-document gates and topology/whitespace checks. +4. Obtain an independent read-only Sol PASS/FAIL review, commit only after PASS, verify exact merge parents and keep remote push outside this request. + ### 2026-08-17 macOS Robot Hotspot Scan Integration Plan 1. Merge reviewed source `55e61b7` into local `main` with a normal no-ff merge, preserving source history while excluding the source-owned task record from the final tree. @@ -309,6 +326,10 @@ Gate result: ## Outcome +- Prepared a normal no-ff merge of reviewed AI Programming source `65040730ec229436b27e0e1a34ca16599b534996` onto local `main` `b734867`; Git reported no textual conflicts, current project-cover behavior remains present, and the source task record remains only on feature history. +- Integrated application-side per-Session run/error/queue isolation, an independent hard 10-second startup acknowledgement deadline, typed pre-send runtime/Agent pending responses, generation/fingerprint Agent readiness, bounded manager/project FIFO acceptance and timeout lease revocation. Ordinary message/command/summarize/background sync does not automatically restart, reload or dispose the shared runtime. +- Promoted the source architecture candidate into current state, data flow and module ownership, and recorded verified evidence plus the still-pending real bundled OpenCode/provider two-Session smoke. No claim of upstream simultaneous model execution was added. + - Prepared a normal no-ff merge of project-cover source `145a6ce571d646325092d1e722282babea503954` onto local `main` `da376b0bf37cdd0b5eabe7efc9f1141ae5645411` with no textual conflicts; the source feature task record remains only on the source branch. - Reconciled the obsolete coverless-first-create rule into required first-cover atomic submission while retaining existing-project version-only behavior and the unfulfilled conditional metadata-editing commitment. - Created normal no-ff merge commit `a5f0cf7fc4d18ee1d46aef6c177ffb4433039a4a` with project-cover integration-plan commit `da376b0bf37cdd0b5eabe7efc9f1141ae5645411` as first parent and reviewed source `145a6ce571d646325092d1e722282babea503954` as second parent. @@ -446,6 +467,14 @@ Gate result: ## Verification +- Source closeout before integration: Main focused 164/164, full unit 176 files / 2100 tests, typecheck, full lint with zero errors and seven pre-existing warnings, `build:vite`, focused Electron E2E 1/1, project-document gates and seventh independent Sol Standards/Spec review all passed. +- Multi-Session staged merged-tree high-risk selection: 13 files / 490 tests passed, including OpenCode routes/store/panel/run-machine/client/manager, provider routes/store/settings, project config/Agent readiness, Makelore background sync and login regression coverage. +- Multi-Session staged merged-tree full unit suite: 176 files / 2106 tests passed with four workers; the additional mainline project-cover regressions remain green. +- Multi-Session staged merged-tree `pnpm run typecheck` and `pnpm run lint:check` passed; lint reported zero errors and the same seven pre-existing warnings. +- Multi-Session staged merged-tree `pnpm run build:vite` passed for Renderer, Electron Main and Preload; only existing mixed-import and chunk-size warnings remain. +- Focused Electron E2E `tests/e2e/opencode-multichat-runtime.spec.ts` passed 1/1 through the built Main/Renderer Host stub, preserving Session A busy state while Session B terminates independently without retry. +- Independent final staged-merge Sol review returned Standards `PASS`, Spec `PASS`, overall `PASS`, with no blocking findings. It independently reran 13 files / 490 tests, confirmed exact `HEAD=b734867` / `MERGE_HEAD=6504073` topology, source-task-record exclusion, no unmerged or unstaged changes, preserved project-cover behavior, canonical document consistency, and the explicit non-claim for real provider concurrency. + - Project-cover source verification: 3 focused Vitest files / 76 tests, typecheck, scoped ESLint, Vite production build, focused Electron E2E 1/1, and cross-repository Sol review all passed. - Project-cover merged tree: 3 focused Vitest files / 76 tests, `tsc --noEmit`, scoped ESLint, Renderer/Main/Preload Vite production build, and focused Electron E2E 1/1 all passed; build emitted only the existing dynamic-import and chunk-size warnings. - `check_project_docs.py`, task-aware `check_doc_drift.py`, and `git diff --cached --check` passed for the prepared project-cover merge. @@ -596,6 +625,9 @@ Gate result: ## Follow-ups +- Before claiming true simultaneous model execution, run the opt-in real bundled OpenCode two-Session smoke with an explicitly configured test provider. Current automation proves application isolation, bounded failure and no replay, not provider/runtime concurrency. +- If runtime Agent edits must apply immediately, first add an upstream directory-scoped invalidation API or authoritative whole-instance quiescence oracle. Until then, retain manual restart after active replies finish and do not infer dispose/reload safety from `/session/status`. + - Before releasing the per-user module-entry policy, deploy the Works `module_access` migration and `/api/auth/me` contract, build and install a new Makelore package, then use a real account to disable Code, Canvas, Learning and Robot one at a time and smoke chooser/root/deep/alias behavior, global settings, terminal `401`, and independent server-side API authorization. - Before releasing Learning, run a real Works account through catalog, generation/material/cancel-resume, bounded download, offline multi-module playback, progress, Agent, ASR and PBL/scoring using the exact production Stage artifact. Validate packaged loopback cookie/nonce behavior on Windows and a signed macOS build; current automation is not that acceptance. - First-project cover upload is implemented by client source `145a6ce` and server source `407c883` / local merge `0cedfc4`; before release, deploy the server, package the client, and verify a real account/object-store flow including create conflict, cover failure, and version-upload blocking. Editing metadata on an existing draft/published project still requires a server-owned revision/ETag plus draft-only conditional write; until then existing projects remain version-only. @@ -622,6 +654,8 @@ Gate result: ## Promotion Candidates +- The `6504073` candidate is promoted into current state, module map, data flow, evidence and commitments: per-Session run/error/startup state, hard 10-second explicit startup acknowledgement, no uncertain replay, Main-owned generation/fingerprint Agent readiness, typed pre-send runtime/config pending, bounded manager/project acceptance and no ordinary-path shared-runtime restart/reload/dispose. Real provider concurrency and immediate Agent hot reload remain explicit pending validations rather than integrated claims. + - The `3b799af` module-access candidate is promoted into current state, architecture/data flow, business rules, success criteria, evidence and commitments: Electron Main projects only four booleans from `/api/auth/me`; missing values are enabled, server `design` maps to client `painting`, disabled card/root/deep/alias entry stops before layout/module initialization, Code provider waits for policy hydration, terminal `401` clears both session layers, and `/settings` remains global. This is a client entry policy, not API authorization; deployment/package/real-account validation remains pending. - Remote `01bee31` facts and the reviewed merge hardening were promoted into README, AGENTS, success criteria, current state, architecture/data flow, business rules, glossary, evidence and commitments: Learning is enabled but remains Main-owned, account-partitioned, bounded, artifact-verified and pending real Works/signed-package acceptance; `game-engine` is removed and planning files belong in the project root. - Remote `26b52d7` facts were promoted into current state, architecture, domain rules, glossary, evidence, README, and release commitments: Prompt Museum remains read-only/server-driven, Quote pricing is service-owned, Canvas deletion is an explicit Workspace mutation, development is cloud-default, UI language is Chinese-only, and `game-engine` is an optional bundled Skill. Production Museum content and real-account billing/deletion acceptance remain pending commitments rather than completed evidence. diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 5916698..5a2694f 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -4,6 +4,7 @@ Use this index for searchable, traceable evidence records. | Date | Topic | Status | Source | Detail | |---|---|---|---|---| +| 2026-08-17 | AI 编程多 Session 应用侧隔离与 runtime/Agent acceptance | 客户端实现及独立复审通过;真实 bundled OpenCode/provider 并发 smoke 待验收 | 源任务 `20260817-multichat-runtime-fix-f3a91c`、源提交 `6504073` | 最终 176 files / 2100 unit tests、Main focused 164/164、typecheck、full lint(0 errors / 7 existing warnings)、Renderer/Main/Preload build、Electron E2E 1/1 与第七轮独立 Sol Standards/Spec review PASS。覆盖 per-Session run/error/queue、hard 10s startup ACK、无自动重放、Agent fingerprint + fresh generation、typed pre-send pending、manager/project FIFO、timeout lease revocation、A-held/B-abort/C ordering 和无普通路径自动 runtime restart/dispose;不据此宣称真实 provider 同时执行两个模型 turn。 | | 2026-08-17 | 首次项目封面原子提交 | 客户端与匹配服务端本地集成;发布/部署待验收 | 客户端源 `145a6ce`、服务端源 `407c883` / merge `0cedfc4`、任务 `20260817-project-cover-upload-a6a98e56` | 首次弹窗必选 PNG/JPEG/WebP ≤10 MiB,提供预览/文件名/重选;Main 用 `metadata` + `cover` multipart 创建,409 不上传版本,已有项目 version-only。源验证通过 76 单测、typecheck、scoped ESLint、Vite build、Electron E2E 1/1 和跨仓库 Sol review;未执行真实账号、生产对象存储或安装包 smoke。 | | 2026-08-17 | Makelore 用户级四模块入口策略 | 客户端实现与独立复审通过;服务端部署、新包与真实账号 smoke 待验收 | 源任务 `20260817-makelore-module-access-6f2a91c4`、源 tip `3b799af` | 4 files / 69 focused tests、175 files / 2047 full tests、TypeScript、scoped ESLint、Renderer/Main/Preload production build 与最终独立 Sol review PASS。覆盖 Main 四布尔安全投影、缺失默认开启、`design` → `painting`、卡片置灰不可点、根/深层/别名路由初始化前阻断、Code policy hydration、terminal `401` 双层清会话和全局 `/settings`;不据此宣称 API 授权或生产发布已验收 | | 2026-08-17 | 远程 `01bee31` Learning 主线集成与安全收口 | 合并树自动化与独立双轴复审通过;真实 Works、固定生产播放器 artifact 与签名安装包待验收 | 远程提交 `01bee31`、集成任务 `20260813-sync-push-main-9c2f71` | pnpm 10.33.4 frozen install、26 files / 418 pre-review focused、175 files / 1944 pre-review full;四轮审查修复后统一 9 files / 161、最终 175 files / 2028 full、typecheck、lint(0 errors / 7 existing warnings)、Renderer/Main/Preload build 与最终 Electron E2E 4/4 通过(更早跨模块选择 9/9 亦通过);第五轮 Standards/Spec 最终复审 PASS、无 P0-P3。最终树严格投影 generation IPC;分离显式 player registration 与无副作用 identity resolve;manifest 只接受相对 `audio|media|fonts`,权威 root 注入单一 module 前缀,consumer 生成 URL 已穿过 registered ZIP 的真实 HTTP/Woff2/MIME/security-header 测试;保持 fixed-binding 账号/Token/fetch/401 guard、512 MiB/ZIP/同源 5 跳下载、nonce player、exact-source/origin bridge 与可恢复 profile gate。该 checkpoint 因当时缺少 revision/cover cleanup 合同采用 coverless first-create、existing version-only、竞态 fail-closed;当前客户端 `145a6ce` 与服务端 `407c883` / `0cedfc4` 已仅替换首次 coverless 限制,不据此宣称生产服务或签名包已验收 | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index 17f98cb..89404e9 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -4,6 +4,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | Date | Commitment | Trigger / Due | Owner | Status | Next Action | |---|---|---|---|---|---| +| 2026-08-17 | 验收 AI 编程真实双 Session runtime/provider 行为与 Agent 刷新边界 | 升级 bundled OpenCode、改变 provider 并发策略,或对外宣称双会话真实并发/即时 Agent 热更新前 | 客户端/OpenCode/provider 集成 | Pending | 使用明确配置的测试 provider 运行 opt-in bundled OpenCode 双 Session smoke,区分应用隔离与上游并发/串行/限流;若要求运行期即时应用 Agent 编辑,先提供 directory-scoped authoritative invalidation 或 whole-instance quiescence oracle。在此之前只允许回复完成后的手动 runtime restart,不从 `/session/status` 推断 dispose/reload 安全。 | | 2026-08-17 | 验收用户级四模块入口策略的真实发布链 | 发布包含源 tip `3b799af` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API,重新打包并安装 Makelore;使用真实账号依次关闭 Code、Canvas、Learning、Robot,重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 | | 2026-08-16 | 验收 AI Learning 的真实 Works、课程包与 production Stage 发布链 | 发布包含 `01bee31` Learning 行为的安装包前 | 客户端/服务端/发布集成 | Pending | 使用真实账号核对课程广场、单课生成(无材料/5 文件边界)、取消/恢复/finalize、同源最多 5 跳下载、512 MiB archive 上限、大小/SHA-256/原子安装、账号切换隔离、离线多模块播放、进度、Agent、ASR、PBL/评分权益;以固定 URL/SHA-256 的 player artifact 构建并完成 Windows 与签名 macOS 安装包 smoke,验证 packaged Chromium 的 loopback cookie/nonce,不以客户端单测替代生产验收 | | 2026-08-17 | 验收首次项目封面并补齐已有资料条件写 | 发布包含源 `145a6ce` 的 Makelore 前,或启用 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Partial / Pending | 首次封面已由服务端源 `407c883` 与客户端源 `145a6ce` 通过单请求绑定和失败补偿落地;仍需先部署服务端、重新打包客户端,并以真实对象存储/账号验证 404→create 409、cover 失败和版本阻断。已有资料编辑继续等待 metadata revision/ETag 与 draft-only 条件写;在此之前保持 existing version-only。 | diff --git a/README.md b/README.md index dc00d5d..f9babdf 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 一个伙伴可以拥有多条互相独立的 OpenCode Session;伙伴、会话、归档时间、置顶和未读数分别由项目配置与 `.niancode/conversations.json` 保存。 - 伙伴展开后按时间展示会话,默认显示前五条,更多会话通过“更多会话”展开;每条会话只显示一行精简的最新消息预览和右侧时间,归档按钮仅在悬浮或聚焦会话卡片时出现,选中伙伴会在卡片上保持明确的展开状态反馈。 - 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。 -- 不同 Session 由 OpenCode 自己并发运行;同一 Session 的后续消息按顺序排队。Makelore 只展示运行中、待处理和未读状态,不增加额外的全局并发锁。 +- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。 +- 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间新建、编辑或删除伙伴只更新项目配置与生成文件,不会自动重启、reload 或 dispose 共享运行时,以免打断其他 Session;这类变更会保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时再发送。 - 归档伙伴或单个会话前必须确认;归档会停止对应运行、保留历史,并可从列表底部恢复。未读按会话记录,打开一个会话只清除它自己的未读数。 - OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩在聊天时间线实际发生的位置显示为独立事件:自动压缩使用“正在优化对话”/“已优化对话”文案,手动执行 `/compact` 使用“正在压缩上下文”/“已压缩上下文”文案。压缩进行中使用弱化动画,完成后变为静态状态并保留在原位置;触发阈值与同一 Session 的消息排队语义保持不变。 diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index 407ac90..3f16269 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -32,9 +32,20 @@ import { getHostApiToken } from '../server'; import { listProjectKnowledge, readProjectConfig, + readProjectConfigSnapshot, writeProjectConfig, type ProjectConfigReadResult, } from '../../opencode/project-config'; +import { + acceptProjectAgentRuntime, + mutateProjectAgentRuntime, + observeProjectAgentRuntime, + observeProjectAgentRuntimeGeneration, +} from '../../opencode/project-agent-runtime'; +import { + withRuntimeAcceptanceTimeout, + withRuntimeConfigCoordinator, +} from '../../opencode/runtime-config-readiness'; import { readProjectConversationState, writeProjectConversationState, @@ -59,6 +70,7 @@ const bundledCourseSkillIds = new Set(BUNDLED_COURSE_SKILL_IDS); type ActiveProjectClient = ReturnType; type OpencodePromptModel = NonNullable; type RuntimeStatus = ReturnType; +const activeProjectPathByClient = new WeakMap(); interface ExpectedUserModelProxyConfig { usesLocalProxy: boolean; @@ -256,12 +268,29 @@ async function loadRuntimeConfigSummaryForPrompt(): Promise | null = null; +const directWorksSquareCredentialRefreshPromises = new WeakMap< + object, + Record<'apply' | 'defer', Promise | null> +>(); -async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise { +function credentialRefreshPromisesForManager( + manager: object, +): Record<'apply' | 'defer', Promise | null> { + let promises = directWorksSquareCredentialRefreshPromises.get(manager); + if (!promises) { + promises = { apply: null, defer: null }; + directWorksSquareCredentialRefreshPromises.set(manager, promises); + } + return promises; +} + +async function refreshDirectWorksSquareCredentialBeforePrompt( + ctx: HostApiContext, + runtimeRefresh: 'apply' | 'defer', +): Promise { const account = await getProviderService().getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); if (account?.metadata?.worksSquareCredentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) { - return; + return false; } const expiresAt = Date.parse(account.metadata.worksSquareCredentialExpiresAt ?? ''); @@ -269,7 +298,7 @@ async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContex Number.isFinite(expiresAt) && expiresAt > Date.now() + DIRECT_WORKS_SQUARE_CREDENTIAL_REFRESH_SKEW_MS ) { - return; + return false; } const accessToken = await getValidWorksSquareAccessToken(); @@ -277,18 +306,88 @@ async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContex throw new Error('Works Square access token unavailable; please log in again'); } - await importCurrentUserModelConfig(ctx, accessToken); + const result = await importCurrentUserModelConfig(ctx, accessToken, { runtimeRefresh }); logger.info('[opencode-route] Refreshed direct Works Square AI gateway credential before prompt'); + return result.runtimeRefreshRequired; } -async function ensureDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise { - if (!directWorksSquareCredentialRefreshPromise) { - directWorksSquareCredentialRefreshPromise = refreshDirectWorksSquareCredentialBeforePrompt(ctx) +async function ensureDirectWorksSquareCredentialBeforePrompt( + ctx: HostApiContext, + runtimeRefresh: 'apply' | 'defer' = 'apply', +): Promise { + const promises = credentialRefreshPromisesForManager(ctx.opencodeManager as object); + if (!promises[runtimeRefresh]) { + promises[runtimeRefresh] = refreshDirectWorksSquareCredentialBeforePrompt( + ctx, + runtimeRefresh, + ) .finally(() => { - directWorksSquareCredentialRefreshPromise = null; + promises[runtimeRefresh] = null; }); } - await directWorksSquareCredentialRefreshPromise; + return await promises[runtimeRefresh]; +} + +async function prepareRuntimeExecution( + res: ServerResponse, + ctx: HostApiContext, +): Promise { + try { + if (await ensureDirectWorksSquareCredentialBeforePrompt(ctx, 'defer')) { + return true; + } + } catch (error) { + logger.warn('[opencode-route] Failed to defer runtime credential refresh', error); + sendRuntimeConfigPending(res, ctx); + return false; + } + return true; +} + +async function coordinateRuntimeExecution( + res: ServerResponse, + ctx: HostApiContext, + operation: ( + signal: AbortSignal, + markPending: () => void, + response: ServerResponse, + ) => Promise, +): Promise { + if (!await prepareRuntimeExecution(res, ctx)) return false; + return await withRuntimeConfigCoordinator(ctx.opencodeManager, async (lease) => { + if (lease.isRefreshPending()) { + sendRuntimeConfigPending(res, ctx); + return false; + } + let operationMarkedPending = false; + try { + await withRuntimeAcceptanceTimeout(async (signal) => { + const guardedResponse = new Proxy(res, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + if (signal.aborted) return property === 'write' ? false : undefined; + return Reflect.apply(value, target, args); + }; + }, + set(target, property, value, receiver) { + if (signal.aborted) return true; + return Reflect.set(target, property, value, receiver); + }, + }); + const markPending = () => { + operationMarkedPending = true; + lease.markRefreshPending(); + }; + await operation(signal, markPending, guardedResponse); + }); + } catch (error) { + if (operationMarkedPending) lease.retainRefreshPending(); + throw error; + } + return true; + }); } async function resolveRuntimePromptModel( @@ -409,9 +508,14 @@ function expectedUserModelProxyConfig( }; } -async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Promise { +async function rebindLocalProxyHostApiTokenIfNeeded( + expectedBaseUrl: string, + markPending: () => void, + signal?: AbortSignal, +): Promise { const providerService = getProviderService(); const account = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); + signal?.throwIfAborted(); const accountBaseUrl = normalizeRuntimeBaseUrl(account?.baseUrl); if (!account || accountBaseUrl !== expectedBaseUrl) { return false; @@ -423,15 +527,18 @@ async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Pr } const existingApiKey = await providerService.getAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID); + signal?.throwIfAborted(); if (existingApiKey === currentHostApiToken) { return false; } + markPending(); await providerService.updateAccount( NIANCODE_USER_MODEL_ACCOUNT_ID, account, currentHostApiToken, ); + signal?.throwIfAborted(); return true; } @@ -439,13 +546,16 @@ async function runtimeUsesExpectedUserModelProxy( status: RuntimeStatus, directory: string, expectedConfig: ExpectedUserModelProxyConfig, + signal?: AbortSignal, ): Promise { if (status.state !== 'running' || !status.url) return false; const client = createOpencodeClient({ baseUrl: status.url, directory, }); - const runtimeConfig = await client.getConfig(); + signal?.throwIfAborted(); + const runtimeConfig = await client.getConfig({ signal }); + signal?.throwIfAborted(); const actualBaseUrl = providerBaseUrlFromRuntimeConfig( runtimeConfig, NIANCODE_USER_MODEL_ACCOUNT_ID, @@ -473,6 +583,9 @@ async function ensureRuntimeUserModelProxyConfig( status: RuntimeStatus, directory: string, summary?: OpencodeRuntimeConfigSummary, + allowRestart = true, + markPending: () => void = () => undefined, + signal?: AbortSignal, ): Promise { let expectedConfig: ExpectedUserModelProxyConfig | null; try { @@ -480,7 +593,13 @@ async function ensureRuntimeUserModelProxyConfig( summary ?? await buildNianCodeRuntimeConfigSummary(), ); } catch (error) { + if (signal?.aborted) throw error; logger.warn('[opencode-route] Failed to inspect generated runtime provider config', error); + if (!allowRestart) { + markPending(); + sendRuntimeConfigPending(res, ctx); + return null; + } return status; } if (!expectedConfig) return status; @@ -488,8 +607,13 @@ async function ensureRuntimeUserModelProxyConfig( try { if ( expectedConfig.usesLocalProxy - && await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl) + && await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl, markPending, signal) ) { + if (!allowRestart) { + markPending(); + sendRuntimeConfigPending(res, ctx); + return null; + } logger.warn('[opencode-route] Restarting runtime because local AI proxy Host API token changed'); const restartedStatus = await ctx.opencodeManager.restart(); if (restartedStatus.state !== 'running' || !restartedStatus.url) { @@ -502,17 +626,30 @@ async function ensureRuntimeUserModelProxyConfig( status = restartedStatus; } } catch (error) { + if (signal?.aborted) throw error; logger.warn('[opencode-route] Failed to rebind local AI proxy Host API token', error); + if (!allowRestart) { + markPending(); + sendRuntimeConfigPending(res, ctx); + return null; + } } try { - if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig)) { + if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig, signal)) { return status; } } catch (error) { + if (signal?.aborted) throw error; logger.warn('[opencode-route] Failed to inspect running runtime provider config', error); } + if (!allowRestart) { + markPending(); + sendRuntimeConfigPending(res, ctx); + return null; + } + logger.warn('[opencode-route] Restarting runtime because user model provider config is stale', { expectedModelIds: expectedConfig.modelIds, expectedImageInputModelIds: expectedConfig.imageInputModelIds, @@ -528,10 +665,11 @@ async function ensureRuntimeUserModelProxyConfig( } try { - if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig)) { + if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig, signal)) { return restartedStatus; } } catch (error) { + if (signal?.aborted) throw error; logger.warn('[opencode-route] Failed to inspect restarted runtime provider config', error); } @@ -542,6 +680,30 @@ async function ensureRuntimeUserModelProxyConfig( return null; } +function sendRuntimeConfigPending(res: ServerResponse, ctx: HostApiContext): void { + sendJson(res, 409, { + success: false, + error: '运行时配置已更新。请在当前回复完成后手动重启运行时再试。', + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + terminal: true, + retryable: false, + runtimeGeneration: ctx.opencodeManager.getRuntimeGeneration?.() ?? 0, + }); +} + +function sendAgentRegistryPending(res: ServerResponse, runtimeGeneration: number): void { + sendJson(res, 409, { + success: false, + error: '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。', + code: 'OPENCODE_AGENT_REGISTRY_PENDING', + promptSent: false, + terminal: true, + retryable: false, + runtimeGeneration, + }); +} + function buildNewProjectPath(parentPath: string | undefined, projectName: string | undefined): string { const normalizedParent = parentPath?.trim(); const normalizedName = projectName?.trim(); @@ -652,8 +814,11 @@ function sendProjectActivationFailure( async function getValidatedActiveProjectForRuntime( res: ServerResponse, ctx: HostApiContext, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const activeProject = await ctx.opencodeProjectStore.getActiveProject() as TProject | null; + signal?.throwIfAborted(); if (!activeProject) { sendJson(res, 409, { success: false, @@ -663,16 +828,25 @@ async function getValidatedActiveProjectForRuntime void; + signal?: AbortSignal; + } = {}, runtimeConfigSummary?: OpencodeRuntimeConfigSummary, ): Promise { let status = ctx.opencodeManager.getStatus(); @@ -692,7 +871,9 @@ async function createClientForActiveProject( return null; } - const activeProject = await getValidatedActiveProjectForRuntime(res, ctx); + options.signal?.throwIfAborted(); + const activeProject = await getValidatedActiveProjectForRuntime(res, ctx, options.signal); + options.signal?.throwIfAborted(); if (!activeProject) { return null; } @@ -703,15 +884,28 @@ async function createClientForActiveProject( status, activeProject.path, runtimeConfigSummary, + options.allowRuntimeRestart, + options.markRuntimeConfigPending, + options.signal, ); + options.signal?.throwIfAborted(); if (!ensuredStatus) return null; status = ensuredStatus; + await observeProjectAgentRuntimeGeneration( + ctx.opencodeManager, + activeProject.path, + options.signal, + ); + options.signal?.throwIfAborted(); } - return createOpencodeClient({ + options.signal?.throwIfAborted(); + const client = createOpencodeClient({ baseUrl: status.url, directory: activeProject.path, }); + activeProjectPathByClient.set(client, activeProject.path); + return client; } async function getActiveProjectContext(res: ServerResponse, ctx: HostApiContext) { @@ -1072,7 +1266,19 @@ export async function handleOpencodeRoutes( if (!body.projectId || !body.config) throw new Error('Missing project configuration'); const project = await findProjectById(ctx, body.projectId); if (!project) throw new Error('Project not found'); - const config = await writeProjectConfig(project.path, body.config); + const config = await mutateProjectAgentRuntime( + ctx.opencodeManager, + project.path, + async () => { + const previous = await readProjectConfigSnapshot(project.path); + const saved = await writeProjectConfig(project.path, body.config); + return { + previousConfig: previous.status === 'valid' ? previous.config : null, + config: saved, + value: saved, + }; + }, + ); const status = ctx.opencodeManager.getStatus(); sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status }); } catch (error) { @@ -1323,29 +1529,42 @@ export async function handleOpencodeRoutes( ); if (sessionSummarizeMatch && req.method === 'POST') { try { - await ensureDirectWorksSquareCredentialBeforePrompt(ctx); - const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt(); - const client = await createClientForActiveProject( - res, - ctx, - { ensureUserModelProxyConfig: true }, - runtimeConfigSummary, - ); - if (!client) return true; - const body = await parseJsonBody<{ model?: unknown }>(req); - const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model); - if (body.model !== undefined && !explicitModel) { - sendJson(res, 400, { success: false, error: 'Invalid model reference' }); - return true; - } - const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary); - if (!model) { - sendJson(res, 409, { success: false, error: 'No runtime model configured' }); - return true; - } - const sessionID = decodeURIComponent(sessionSummarizeMatch[1]); - await client.summarizeSession(sessionID, model); - sendJson(res, 202, { success: true }); + await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => { + signal.throwIfAborted(); + const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt(); + signal.throwIfAborted(); + const client = await createClientForActiveProject( + response, + ctx, + { + ensureUserModelProxyConfig: true, + allowRuntimeRestart: false, + markRuntimeConfigPending: markPending, + signal, + }, + runtimeConfigSummary, + ); + signal.throwIfAborted(); + if (!client) return; + const body = await parseJsonBody<{ model?: unknown }>(req); + signal.throwIfAborted(); + const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model); + if (body.model !== undefined && !explicitModel) { + sendJson(response, 400, { success: false, error: 'Invalid model reference' }); + return; + } + const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary); + signal.throwIfAborted(); + if (!model) { + sendJson(response, 409, { success: false, error: 'No runtime model configured' }); + return; + } + const sessionID = decodeURIComponent(sessionSummarizeMatch[1]); + signal.throwIfAborted(); + await client.summarizeSession(sessionID, model, { signal }); + signal.throwIfAborted(); + sendJson(response, 202, { success: true }); + }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } @@ -1357,15 +1576,23 @@ export async function handleOpencodeRoutes( ); if (sessionCommandMatch && req.method === 'POST') { try { - await ensureDirectWorksSquareCredentialBeforePrompt(ctx); - const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt(); - const client = await createClientForActiveProject( - res, - ctx, - { ensureUserModelProxyConfig: true }, - runtimeConfigSummary, - ); - if (!client) return true; + await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => { + signal.throwIfAborted(); + const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt(); + signal.throwIfAborted(); + const client = await createClientForActiveProject( + response, + ctx, + { + ensureUserModelProxyConfig: true, + allowRuntimeRestart: false, + markRuntimeConfigPending: markPending, + signal, + }, + runtimeConfigSummary, + ); + signal.throwIfAborted(); + if (!client) return; const body = await parseJsonBody<{ command?: unknown; arguments?: unknown; @@ -1374,6 +1601,7 @@ export async function handleOpencodeRoutes( variant?: unknown; parts?: unknown; }>(req); + signal.throwIfAborted(); const command = parseRequiredCommandName(body.command); const argumentsText = typeof body.arguments === 'string' && body.arguments.length <= MAX_COMMAND_ARGUMENTS @@ -1381,8 +1609,8 @@ export async function handleOpencodeRoutes( : null; const parts = normalizeCommandParts(body.parts); if (!command || argumentsText === null || !parts) { - sendJson(res, 400, { success: false, error: 'Invalid command payload' }); - return true; + sendJson(response, 400, { success: false, error: 'Invalid command payload' }); + return; } const sessionID = decodeURIComponent(sessionCommandMatch[1]); const agent = parseOptionalCommandIdentifier(body.agent); @@ -1394,24 +1622,49 @@ export async function handleOpencodeRoutes( || (body.variant !== undefined && !variant) ); if (invalidOptionalField) { - sendJson(res, 400, { success: false, error: 'Invalid command runtime context' }); - return true; + sendJson(response, 400, { success: false, error: 'Invalid command runtime context' }); + return; } - logger.info('[opencode-route] Executing session command', { - sessionID, - command, - partCount: parts.length, - fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []), + const executeCommand = async (): Promise => { + logger.info('[opencode-route] Executing session command', { + sessionID, + command, + partCount: parts.length, + fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []), + }); + signal.throwIfAborted(); + await client.executeSessionCommand(sessionID, { + command, + arguments: argumentsText, + ...(agent ? { agent } : {}), + ...(model ? { model } : {}), + ...(variant ? { variant } : {}), + ...(parts.length ? { parts } : {}), + }, { signal }); + signal.throwIfAborted(); + }; + if (agent) { + const projectPath = activeProjectPathByClient.get(client); + if (!projectPath) throw new Error('Active project runtime context is unavailable'); + const acceptance = await acceptProjectAgentRuntime( + ctx.opencodeManager, + projectPath, + client, + agent, + executeCommand, + signal, + ); + signal.throwIfAborted(); + if (!acceptance.ready) { + sendAgentRegistryPending(response, acceptance.runtimeGeneration); + return; + } + } else { + await executeCommand(); + } + signal.throwIfAborted(); + sendJson(response, 202, { success: true }); }); - await client.executeSessionCommand(sessionID, { - command, - arguments: argumentsText, - ...(agent ? { agent } : {}), - ...(model ? { model } : {}), - ...(variant ? { variant } : {}), - ...(parts.length ? { parts } : {}), - }); - sendJson(res, 202, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } @@ -1696,15 +1949,23 @@ export async function handleOpencodeRoutes( if (sessionMessagesMatch && req.method === 'POST') { try { - await ensureDirectWorksSquareCredentialBeforePrompt(ctx); - const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt(); - const client = await createClientForActiveProject( - res, - ctx, - { ensureUserModelProxyConfig: true }, - runtimeConfigSummary, - ); - if (!client) return true; + await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => { + signal.throwIfAborted(); + const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt(); + signal.throwIfAborted(); + const client = await createClientForActiveProject( + response, + ctx, + { + ensureUserModelProxyConfig: true, + allowRuntimeRestart: false, + markRuntimeConfigPending: markPending, + signal, + }, + runtimeConfigSummary, + ); + signal.throwIfAborted(); + if (!client) return; const body = await parseJsonBody<{ text?: string; files?: unknown; @@ -1712,39 +1973,66 @@ export async function handleOpencodeRoutes( agent?: unknown; model?: unknown; }>(req); + signal.throwIfAborted(); const text = typeof body.text === 'string' ? body.text.trim() : ''; if (!text) { - sendJson(res, 400, { success: false, error: 'Missing message text' }); - return true; + sendJson(response, 400, { success: false, error: 'Missing message text' }); + return; } const sessionID = decodeURIComponent(sessionMessagesMatch[1]); const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model); if (body.model !== undefined && !explicitModel) { - sendJson(res, 400, { success: false, error: 'Invalid model reference' }); - return true; + sendJson(response, 400, { success: false, error: 'Invalid model reference' }); + return; } const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary); + signal.throwIfAborted(); const files = normalizePromptFileParts(body.files); const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : ''; const agent = typeof body.agent === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agent.trim()) ? body.agent.trim() : ''; - logger.info('[opencode-route] Starting session prompt', { - sessionID, - runtimeUrl: ctx.opencodeManager.getStatus().url ?? null, - model: promptModelLogRef(model), - textLength: text.length, - fileCount: files.length, - fileMimes: files.map((file) => file.mime), + const acceptPrompt = async (): Promise => { + logger.info('[opencode-route] Starting session prompt', { + sessionID, + runtimeUrl: ctx.opencodeManager.getStatus().url ?? null, + model: promptModelLogRef(model), + textLength: text.length, + fileCount: files.length, + fileMimes: files.map((file) => file.mime), + }); + signal.throwIfAborted(); + await client.promptSessionAsync(sessionID, { + text, + ...(userContext ? { system: userContext } : {}), + ...(agent ? { agent } : {}), + ...(files.length ? { files } : {}), + ...(model ? { model } : {}), + }, { signal }); + signal.throwIfAborted(); + }; + if (agent) { + const projectPath = activeProjectPathByClient.get(client); + if (!projectPath) throw new Error('Active project runtime context is unavailable'); + const acceptance = await acceptProjectAgentRuntime( + ctx.opencodeManager, + projectPath, + client, + agent, + acceptPrompt, + signal, + ); + signal.throwIfAborted(); + if (!acceptance.ready) { + sendAgentRegistryPending(response, acceptance.runtimeGeneration); + return; + } + } else { + await acceptPrompt(); + } + signal.throwIfAborted(); + sendJson(response, 202, { success: true }); }); - await client.promptSessionAsync(sessionID, { - text, - ...(userContext ? { system: userContext } : {}), - ...(agent ? { agent } : {}), - ...(files.length ? { files } : {}), - ...(model ? { model } : {}), - }); - sendJson(res, 202, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } @@ -1758,7 +2046,10 @@ export async function handleOpencodeRoutes( if (url.pathname === '/api/opencode/start' && req.method === 'POST') { try { - const status = await ctx.opencodeManager.start(); + const status = await withRuntimeConfigCoordinator( + ctx.opencodeManager, + async () => await ctx.opencodeManager.start(), + ); sendJson(res, 200, { success: true, status }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -1768,7 +2059,10 @@ export async function handleOpencodeRoutes( if (url.pathname === '/api/opencode/stop' && req.method === 'POST') { try { - await ctx.opencodeManager.stop(); + await withRuntimeConfigCoordinator( + ctx.opencodeManager, + async () => await ctx.opencodeManager.stop(), + ); sendJson(res, 200, { success: true, status: ctx.opencodeManager.getStatus() }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -1778,7 +2072,10 @@ export async function handleOpencodeRoutes( if (url.pathname === '/api/opencode/restart' && req.method === 'POST') { try { - const status = await ctx.opencodeManager.restart(); + const status = await withRuntimeConfigCoordinator( + ctx.opencodeManager, + async () => await ctx.opencodeManager.restart(), + ); sendJson(res, 200, { success: true, status }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 01762b2..52a89b8 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -22,6 +22,10 @@ import { NIANCODE_USER_MODEL_ACCOUNT_LABEL, normalizeImportedUserModelId, } from '../../../shared/user-model-config'; +import { + withRuntimeAcceptanceTimeout, + withRuntimeConfigCoordinator, +} from '../../opencode/runtime-config-readiness'; const legacyProviderRoutesWarned = new Set(); @@ -238,13 +242,22 @@ function localAiProxyBaseUrl(): string { } async function fetchCurrentUserModelConfig(accessToken: string): Promise { - const response = await proxyAwareFetch(createWorksUrl('/api/auth/me/model-config').toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, + const { response, payload } = await withRuntimeAcceptanceTimeout(async (signal) => { + const response = await proxyAwareFetch( + createWorksUrl('/api/auth/me/model-config').toString(), + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal, + }, + ); + signal.throwIfAborted(); + const payload = await readResponsePayload(response); + signal.throwIfAborted(); + return { response, payload }; }); - const payload = await readResponsePayload(response); if (!response.ok) { throw new WorksSquareModelConfigError( response.status >= 400 && response.status < 500 ? response.status : 502, @@ -310,60 +323,98 @@ async function importedProviderApiKeyChanged( export async function importCurrentUserModelConfig( ctx: HostApiContext, accessToken: string, -): Promise<{ account: ProviderAccount; importedModels: string[] }> { + options: { runtimeRefresh?: 'apply' | 'defer' } = {}, +): Promise<{ + account: ProviderAccount; + importedModels: string[]; + runtimeRefreshRequired: boolean; +}> { const providerService = getProviderService(); const modelConfig = await fetchCurrentUserModelConfig(accessToken); - const useLocalAiProxy = modelConfig.credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE; - if (useLocalAiProxy) { - seedWorksSquareAIGatewayCredential({ - accessToken: modelConfig.apiKey, - expiresIn: modelConfig.apiKeyExpiresIn, - oneApiBaseUrl: modelConfig.baseUrl, - }); - } - const nowMs = Date.now(); - const now = new Date(nowMs).toISOString(); - const existing = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); - const accountBaseUrl = useLocalAiProxy ? localAiProxyBaseUrl() : modelConfig.baseUrl; - const accountApiKey = useLocalAiProxy ? getHostApiToken() : modelConfig.apiKey; - const orderedModels = orderImportedModelsForAccount(existing, modelConfig.models); - const account: ProviderAccount = { - id: NIANCODE_USER_MODEL_ACCOUNT_ID, - vendorId: 'custom', - label: modelConfig.label, - authMode: 'api_key', - baseUrl: accountBaseUrl, - apiProtocol: 'openai-completions', - headers: useLocalAiProxy ? undefined : importedUserModelHeaders(modelConfig), - model: orderedModels[0], - fallbackModels: orderedModels.slice(1), - fallbackAccountIds: existing?.fallbackAccountIds, - enabled: true, - isDefault: true, - metadata: importedUserModelMetadata(existing, modelConfig, nowMs, useLocalAiProxy), - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }; - const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account) - || (useLocalAiProxy && await importedProviderApiKeyChanged(providerService, existing, accountApiKey)); + return await withRuntimeConfigCoordinator(ctx.opencodeManager, async (lease) => { + let operationMarkedPending = false; + try { + return await withRuntimeAcceptanceTimeout(async (signal) => { + signal.throwIfAborted(); + const useLocalAiProxy = modelConfig.credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE; + const nowMs = Date.now(); + const now = new Date(nowMs).toISOString(); + const existing = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); + signal.throwIfAborted(); + const accountBaseUrl = useLocalAiProxy ? localAiProxyBaseUrl() : modelConfig.baseUrl; + const accountApiKey = useLocalAiProxy ? getHostApiToken() : modelConfig.apiKey; + const orderedModels = orderImportedModelsForAccount(existing, modelConfig.models); + const account: ProviderAccount = { + id: NIANCODE_USER_MODEL_ACCOUNT_ID, + vendorId: 'custom', + label: modelConfig.label, + authMode: 'api_key', + baseUrl: accountBaseUrl, + apiProtocol: 'openai-completions', + headers: useLocalAiProxy ? undefined : importedUserModelHeaders(modelConfig), + model: orderedModels[0], + fallbackModels: orderedModels.slice(1), + fallbackAccountIds: existing?.fallbackAccountIds, + enabled: true, + isDefault: true, + metadata: importedUserModelMetadata(existing, modelConfig, nowMs, useLocalAiProxy), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account) + || await importedProviderApiKeyChanged(providerService, existing, accountApiKey); + signal.throwIfAborted(); + const runtimeIsActive = ctx.opencodeManager.getStatus().state !== 'stopped'; + const refreshAlreadyPending = lease.isRefreshPending(); + const runtimeRefreshRequired = runtimeIsActive + && (shouldRestartRuntime || refreshAlreadyPending); + const armStoppedApplyForNextFresh = !runtimeIsActive + && refreshAlreadyPending + && options.runtimeRefresh !== 'defer'; + if (runtimeRefreshRequired) { + lease.markRefreshPending(); + operationMarkedPending = true; + } - const savedAccount = existing - ? await providerService.updateAccount( - NIANCODE_USER_MODEL_ACCOUNT_ID, - account, - accountApiKey, - ) - : await providerService.createAccount(account, accountApiKey); + signal.throwIfAborted(); + if (useLocalAiProxy) { + seedWorksSquareAIGatewayCredential({ + accessToken: modelConfig.apiKey, + expiresIn: modelConfig.apiKeyExpiresIn, + oneApiBaseUrl: modelConfig.baseUrl, + }); + } + signal.throwIfAborted(); + const savedAccount = existing + ? await providerService.updateAccount( + NIANCODE_USER_MODEL_ACCOUNT_ID, + account, + accountApiKey, + ) + : await providerService.createAccount(account, accountApiKey); + signal.throwIfAborted(); - await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); - if (shouldRestartRuntime) { - await refreshRunningRuntimeAfterProviderChange(ctx); - } + await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID); + signal.throwIfAborted(); + if (armStoppedApplyForNextFresh) { + lease.markRefreshPending(); + } + if (runtimeRefreshRequired && options.runtimeRefresh !== 'defer') { + await refreshRunningRuntimeAfterProviderChange(ctx); + signal.throwIfAborted(); + } - return { - account: savedAccount, - importedModels: modelConfig.models, - }; + return { + account: savedAccount, + importedModels: modelConfig.models, + runtimeRefreshRequired, + }; + }); + } catch (error) { + if (operationMarkedPending) lease.retainRefreshPending(); + throw error; + } + }); } export async function handleProviderRoutes( @@ -467,11 +518,19 @@ export async function handleProviderRoutes( } if (url.pathname === '/api/provider-accounts/import-user-model-config' && req.method === 'POST') { + let runtimeRefresh: 'apply' | 'defer' = 'apply'; try { - const body = await parseJsonBody<{ accessToken?: unknown }>(req); + const body = await parseJsonBody<{ accessToken?: unknown; runtimeRefresh?: unknown }>(req); + if (body.runtimeRefresh !== undefined) { + if (body.runtimeRefresh !== 'apply' && body.runtimeRefresh !== 'defer') { + throw new Error('Invalid runtimeRefresh mode'); + } + runtimeRefresh = body.runtimeRefresh; + } const result = await importCurrentUserModelConfig( ctx, readRequiredString(body.accessToken, 'accessToken'), + { runtimeRefresh }, ); sendJson(res, 200, { success: true, ...result }); } catch (error) { diff --git a/electron/opencode/client.ts b/electron/opencode/client.ts index e5635d9..3ac5744 100644 --- a/electron/opencode/client.ts +++ b/electron/opencode/client.ts @@ -81,6 +81,10 @@ export interface FindOpencodeFilesOptions { limit?: number; } +export interface OpencodeRuntimeRequestOptions { + signal?: AbortSignal; +} + export interface UpdateOpencodeSessionInput { title?: string; } @@ -93,6 +97,12 @@ export interface OpencodeSkillInfo { entries?: OpencodeSkillEntry[]; } +export interface OpencodeAgentInfo { + name?: string; + id?: string; + [key: string]: unknown; +} + export interface OpencodeSkillEntry { path: string; type: 'file' | 'directory'; @@ -193,6 +203,7 @@ async function parseJsonResponse(response: Response): Promise { export function createOpencodeClient(options: OpencodeClientOptions) { const fetchImpl = options.fetchImpl ?? fetch; const request = async (path: string, init?: RequestInit): Promise => { + init?.signal?.throwIfAborted(); const decorated = decorateOpencodeRequest({ baseUrl: options.baseUrl, path, @@ -253,23 +264,30 @@ export function createOpencodeClient(options: OpencodeClientOptions) { summarizeSession: ( sessionID: string, payload: SummarizeOpencodeSessionInput, + options?: OpencodeRuntimeRequestOptions, ): Promise => request(`/session/${encodeURIComponent(sessionID)}/summarize`, { method: 'POST', body: JSON.stringify(payload), + signal: options?.signal, }), executeSessionCommand: ( sessionID: string, payload: ExecuteOpencodeSessionCommandInput, + options?: OpencodeRuntimeRequestOptions, ): Promise => request(`/session/${encodeURIComponent(sessionID)}/command`, { method: 'POST', body: JSON.stringify(payload), + signal: options?.signal, }), }; return { ...opencodeCommandMethods, + listAgents: (options?: OpencodeRuntimeRequestOptions) => request('/agent', { + signal: options?.signal, + }), listSkills: () => request('/skill'), listSessions: () => request('/session'), createSession: (payload: Record) => request('/session', { @@ -313,7 +331,9 @@ export function createOpencodeClient(options: OpencodeClientOptions) { request(`/question/${encodeURIComponent(requestID)}/reject`, { method: 'POST', }), - getConfig: () => request>('/config'), + getConfig: (options?: OpencodeRuntimeRequestOptions) => request>('/config', { + signal: options?.signal, + }), listPermissions: () => request('/permission'), replyPermission: (requestID: string, reply: OpencodePermissionReply, message?: string) => request(`/permission/${encodeURIComponent(requestID)}/reply`, { @@ -330,12 +350,17 @@ export function createOpencodeClient(options: OpencodeClientOptions) { method: 'POST', body: JSON.stringify(buildTextPartsPayload(payload)), }), - promptSessionAsync: (sessionID: string, payload: SendOpencodeSessionMessageInput) => { + promptSessionAsync: ( + sessionID: string, + payload: SendOpencodeSessionMessageInput, + options?: OpencodeRuntimeRequestOptions, + ) => { const requestPayload = buildTextPartsPayload(payload); logger.info('[opencode-client] Sending prompt_async payload', summarizePromptPayload(sessionID, requestPayload)); return request(`/session/${encodeURIComponent(sessionID)}/prompt_async`, { method: 'POST', body: JSON.stringify(requestPayload), + signal: options?.signal, }); }, getFileStatuses: () => request('/file/status'), diff --git a/electron/opencode/manager.ts b/electron/opencode/manager.ts index 2b5a9db..518b8f7 100644 --- a/electron/opencode/manager.ts +++ b/electron/opencode/manager.ts @@ -23,6 +23,7 @@ import { import { promisify } from 'node:util'; export type OpencodeLifecycleState = 'stopped' | 'starting' | 'running' | 'error'; +export type OpencodeRuntimeGenerationProvenance = 'unknown' | 'starting' | 'fresh' | 'attached'; export interface OpencodeStatus { state: OpencodeLifecycleState; @@ -400,6 +401,7 @@ export class OpencodeManager extends EventEmitter { private cancelStartsBeforeSequence = 0; private runtimeGeneration = 0; private activeRuntimeGeneration: number | null = null; + private activeRuntimeGenerationProvenance: OpencodeRuntimeGenerationProvenance = 'unknown'; private activeStartAttempt: RuntimeStartAttempt | null = null; private readonly trackedUnreleasedPorts = new Set(); private lifecycleBusy = false; @@ -423,6 +425,18 @@ export class OpencodeManager extends EventEmitter { return { ...this.status }; } + getRuntimeGeneration(): number { + return this.status.state === 'starting' || this.status.state === 'running' + ? this.activeRuntimeGeneration ?? 0 + : 0; + } + + getRuntimeGenerationProvenance(): OpencodeRuntimeGenerationProvenance { + return this.status.state === 'starting' || this.status.state === 'running' + ? this.activeRuntimeGenerationProvenance + : 'unknown'; + } + getManagedConfigDir(): string | null { const userDataDir = this.options.userDataDir?.trim(); return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null; @@ -518,6 +532,7 @@ export class OpencodeManager extends EventEmitter { } if (this.activeRuntimeGeneration === generation) { this.activeRuntimeGeneration = null; + this.activeRuntimeGenerationProvenance = 'unknown'; } this.setStatus({ state: 'stopped', port: this.options.port }); } catch (error) { @@ -685,6 +700,7 @@ export class OpencodeManager extends EventEmitter { if (!listeningPort) return; finish(() => { + this.activeRuntimeGenerationProvenance = 'fresh'; this.setStatus({ state: 'running', port: listeningPort, @@ -738,6 +754,7 @@ export class OpencodeManager extends EventEmitter { url: existingServer.url, }, ); + this.activeRuntimeGenerationProvenance = 'attached'; this.setStatus(existingServer); resolve(this.getStatus()); }); @@ -805,6 +822,7 @@ export class OpencodeManager extends EventEmitter { controller: new AbortController(), }; this.activeRuntimeGeneration = attempt.generation; + this.activeRuntimeGenerationProvenance = 'starting'; this.activeStartAttempt = attempt; return attempt; } @@ -997,6 +1015,7 @@ export class OpencodeManager extends EventEmitter { this.recordPortReleaseOutcome(port, true, released); if (released) { this.activeRuntimeGeneration = null; + this.activeRuntimeGenerationProvenance = 'unknown'; this.setStatus({ state: 'stopped', port: this.options.port, diff --git a/electron/opencode/project-agent-runtime.ts b/electron/opencode/project-agent-runtime.ts new file mode 100644 index 0000000..c9c37e9 --- /dev/null +++ b/electron/opencode/project-agent-runtime.ts @@ -0,0 +1,319 @@ +import { realpathSync } from 'node:fs'; +import path from 'node:path'; +import type { ProjectConfig } from '../../shared/project-config'; +import type { OpencodeAgentInfo } from './client'; +import { + buildProjectAgentManifest, + readProjectConfig, +} from './project-config'; + +interface ProjectAgentRuntimeManager { + getRuntimeGeneration?: () => number; + getRuntimeGenerationProvenance?: () => 'unknown' | 'starting' | 'fresh' | 'attached'; +} + +interface ProjectAgentRegistryClient { + listAgents: (options?: { signal?: AbortSignal }) => Promise; +} + +type RuntimeGenerationProvenance = 'unknown' | 'starting' | 'fresh' | 'attached'; + +interface ProjectAgentRuntimeState { + runtimeGeneration: number; + runtimeGenerationProvenance: RuntimeGenerationProvenance; + desiredFingerprint: string; + appliedFingerprint: string | null; + bootstrapCandidateFingerprint: string | null; +} + +export interface ProjectAgentRuntimeSnapshot extends ProjectAgentRuntimeState { + projectPath: string; +} + +export interface ProjectAgentPreflightResult { + ready: boolean; + runtimeGeneration: number; +} + +export type ProjectAgentAcceptanceResult = + | { ready: false; runtimeGeneration: number } + | { ready: true; runtimeGeneration: number; value: T }; + +export interface ProjectAgentRuntimeMutation { + previousConfig: ProjectConfig | null; + config: ProjectConfig; + value: T; +} + +const runtimeStates = new WeakMap>(); +const runtimeLocks = new WeakMap>>(); + +function canonicalProjectPath(projectPath: string): string { + try { + return realpathSync.native(projectPath); + } catch { + return path.resolve(projectPath); + } +} + +function getManagerMap(registry: WeakMap>, manager: object): Map { + let entries = registry.get(manager); + if (!entries) { + entries = new Map(); + registry.set(manager, entries); + } + return entries; +} + +async function withProjectRuntimeLock( + manager: object, + projectPath: string, + operation: () => Promise, + signal?: AbortSignal, +): Promise { + const locks = getManagerMap(runtimeLocks, manager); + const previous = locks.get(projectPath) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.then(() => current); + locks.set(projectPath, queued); + try { + await awaitAbortable(previous, signal); + signal?.throwIfAborted(); + return await operation(); + } finally { + release(); + void queued.then(() => { + if (locks.get(projectPath) === queued) { + locks.delete(projectPath); + } + }); + } +} + +async function loadValidProjectConfig(projectPath: string): Promise { + const result = await readProjectConfig(projectPath); + if (result.status !== 'valid') { + throw new Error('Project configuration is missing or invalid'); + } + return result.config; +} + +function observeState( + manager: ProjectAgentRuntimeManager, + canonicalPath: string, + config: ProjectConfig, +): ProjectAgentRuntimeState { + const states = getManagerMap(runtimeStates, manager as object); + const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; + const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown'; + const desiredFingerprint = buildProjectAgentManifest(config).fingerprint; + const current = states.get(canonicalPath); + if (!current || current.runtimeGeneration !== runtimeGeneration) { + const next = { + runtimeGeneration, + runtimeGenerationProvenance: provenance, + desiredFingerprint, + appliedFingerprint: provenance === 'fresh' ? desiredFingerprint : null, + bootstrapCandidateFingerprint: provenance === 'starting' ? desiredFingerprint : null, + }; + states.set(canonicalPath, next); + return next; + } + current.desiredFingerprint = desiredFingerprint; + if (provenance !== 'fresh') { + current.appliedFingerprint = null; + } else if (current.runtimeGenerationProvenance === 'starting') { + current.appliedFingerprint = current.bootstrapCandidateFingerprint === desiredFingerprint + ? desiredFingerprint + : null; + } else if (current.runtimeGenerationProvenance !== 'fresh') { + current.appliedFingerprint = null; + } + current.runtimeGenerationProvenance = provenance; + return current; +} + +export async function observeProjectAgentRuntime( + manager: ProjectAgentRuntimeManager, + projectPath: string, + knownConfig?: ProjectConfig, + signal?: AbortSignal, +): Promise { + const canonicalPath = canonicalProjectPath(projectPath); + return await withProjectRuntimeLock(manager as object, canonicalPath, async () => { + signal?.throwIfAborted(); + const config = knownConfig ?? await loadValidProjectConfig(canonicalPath); + signal?.throwIfAborted(); + const state = observeState(manager, canonicalPath, config); + return { projectPath: canonicalPath, ...state }; + }, signal); +} + +export async function observeProjectAgentRuntimeGeneration( + manager: ProjectAgentRuntimeManager, + projectPath: string, + signal?: AbortSignal, +): Promise { + const canonicalPath = canonicalProjectPath(projectPath); + return await withProjectRuntimeLock(manager as object, canonicalPath, async () => { + signal?.throwIfAborted(); + const states = getManagerMap(runtimeStates, manager as object); + const current = states.get(canonicalPath); + if (!current) { + const config = await loadValidProjectConfig(canonicalPath); + signal?.throwIfAborted(); + const state = observeState(manager, canonicalPath, config); + return { projectPath: canonicalPath, ...state }; + } + const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; + const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown'; + if (current.runtimeGeneration !== runtimeGeneration) { + current.runtimeGeneration = runtimeGeneration; + current.runtimeGenerationProvenance = provenance; + current.appliedFingerprint = provenance === 'fresh' ? current.desiredFingerprint : null; + current.bootstrapCandidateFingerprint = provenance === 'starting' + ? current.desiredFingerprint + : null; + } else if (provenance !== 'fresh') { + current.appliedFingerprint = null; + current.runtimeGenerationProvenance = provenance; + } else if (current.runtimeGenerationProvenance === 'starting') { + current.appliedFingerprint = current.bootstrapCandidateFingerprint === current.desiredFingerprint + ? current.desiredFingerprint + : null; + current.runtimeGenerationProvenance = provenance; + } else if (current.runtimeGenerationProvenance !== 'fresh') { + current.appliedFingerprint = null; + current.runtimeGenerationProvenance = provenance; + } + return { projectPath: canonicalPath, ...current }; + }, signal); +} + +export async function markProjectAgentRuntimePending( + manager: ProjectAgentRuntimeManager, + projectPath: string, + config: ProjectConfig, +): Promise { + const canonicalPath = canonicalProjectPath(projectPath); + return await withProjectRuntimeLock(manager as object, canonicalPath, async () => { + const state = { + runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0, + runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown', + desiredFingerprint: buildProjectAgentManifest(config).fingerprint, + appliedFingerprint: null, + bootstrapCandidateFingerprint: null, + }; + getManagerMap(runtimeStates, manager as object).set(canonicalPath, state); + return { projectPath: canonicalPath, ...state }; + }); +} + +export async function mutateProjectAgentRuntime( + manager: ProjectAgentRuntimeManager, + projectPath: string, + mutation: () => Promise>, +): Promise { + const canonicalPath = canonicalProjectPath(projectPath); + return await withProjectRuntimeLock(manager as object, canonicalPath, async () => { + const result = await mutation(); + if (result.previousConfig) { + observeState(manager, canonicalPath, result.previousConfig); + observeState(manager, canonicalPath, result.config); + } else { + const state: ProjectAgentRuntimeState = { + runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0, + runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown', + desiredFingerprint: buildProjectAgentManifest(result.config).fingerprint, + appliedFingerprint: null, + bootstrapCandidateFingerprint: null, + }; + getManagerMap(runtimeStates, manager as object).set(canonicalPath, state); + } + return result.value; + }); +} + +function liveAgentIds(agents: OpencodeAgentInfo[]): Set { + return new Set(agents.flatMap((agent) => { + const name = typeof agent.name === 'string' ? agent.name.trim() : ''; + const id = typeof agent.id === 'string' ? agent.id.trim() : ''; + return [name, id].filter(Boolean); + })); +} + +async function awaitAbortable(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return await promise; + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +export async function acceptProjectAgentRuntime( + manager: ProjectAgentRuntimeManager, + projectPath: string, + client: ProjectAgentRegistryClient, + selectedAgentId: string, + accept: () => Promise, + signal?: AbortSignal, +): Promise> { + const canonicalPath = canonicalProjectPath(projectPath); + return await withProjectRuntimeLock(manager as object, canonicalPath, async () => { + signal?.throwIfAborted(); + const config = await loadValidProjectConfig(canonicalPath); + signal?.throwIfAborted(); + const state = observeState(manager, canonicalPath, config); + const configuredAgentIds = config.agents.map((agent) => agent.id); + if ( + !configuredAgentIds.includes(selectedAgentId) + || state.appliedFingerprint !== state.desiredFingerprint + ) { + return { ready: false, runtimeGeneration: state.runtimeGeneration }; + } + + signal?.throwIfAborted(); + const liveIds = liveAgentIds(await awaitAbortable(client.listAgents({ signal }), signal)); + if (configuredAgentIds.some((agentId) => !liveIds.has(agentId))) { + state.appliedFingerprint = null; + return { ready: false, runtimeGeneration: state.runtimeGeneration }; + } + signal?.throwIfAborted(); + const value = await awaitAbortable(accept(), signal); + return { + ready: true, + runtimeGeneration: state.runtimeGeneration, + value, + }; + }, signal); +} + +export async function preflightProjectAgentRuntime( + manager: ProjectAgentRuntimeManager, + projectPath: string, + client: ProjectAgentRegistryClient, + selectedAgentId: string, +): Promise { + const result = await acceptProjectAgentRuntime( + manager, + projectPath, + client, + selectedAgentId, + async () => undefined, + ); + return { ready: result.ready, runtimeGeneration: result.runtimeGeneration }; +} diff --git a/electron/opencode/project-config.ts b/electron/opencode/project-config.ts index cd8fc5d..5973211 100644 --- a/electron/opencode/project-config.ts +++ b/electron/opencode/project-config.ts @@ -1,5 +1,6 @@ import path from 'node:path'; -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { createProjectConfig, isProjectAgentAvatarDataUrl, @@ -164,6 +165,16 @@ export async function readProjectConfig(projectPath: string): Promise { + try { + const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown; + return { status: 'valid', config: normalizeProjectConfig(raw) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }; + return { status: 'invalid', error: error instanceof Error ? error.message : String(error) }; + } +} + function yamlString(value: string): string { return JSON.stringify(value); } @@ -231,6 +242,40 @@ ${skills} ${prompt}`; } +export interface ProjectAgentManifestEntry { + relativePath: string; + content: string; + contentHash: string; +} + +export interface ProjectAgentManifest { + entries: ProjectAgentManifestEntry[]; + fingerprint: string; +} + +function sha256(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +export function buildProjectAgentManifest(config: ProjectConfig): ProjectAgentManifest { + const entries = config.agents + .map((agent) => { + const content = buildAgentMarkdown(config, agent); + return { + relativePath: path.posix.join('agent', `${agent.id}.md`), + content, + contentHash: sha256(content), + }; + }) + .sort((left, right) => ( + left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0 + )); + const fingerprint = sha256(entries + .map((entry) => `${entry.relativePath}\0${entry.contentHash}\n`) + .join('')); + return { entries, fingerprint }; +} + function buildSelectedSkillGuidance(skillIds: string[]): string { const guidance: string[] = []; if (skillIds.includes('frontend-slides')) { @@ -257,10 +302,11 @@ function buildSelectedSkillGuidance(skillIds: string[]): string { async function areMaterializedAgentsCurrent(projectPath: string, config: ProjectConfig): Promise { if (!config.initialized || config.agents.length === 0) return true; - const results = await Promise.all(config.agents.map(async (agent) => { + const manifest = buildProjectAgentManifest(config); + const results = await Promise.all(manifest.entries.map(async (entry) => { try { - const existing = await readFile(path.join(projectPath, '.opencode', 'agent', `${agent.id}.md`), 'utf8'); - return existing === buildAgentMarkdown(config, agent); + const existing = await readFile(path.join(projectPath, '.opencode', entry.relativePath), 'utf8'); + return sha256(existing) === entry.contentHash; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; @@ -269,11 +315,41 @@ async function areMaterializedAgentsCurrent(projectPath: string, config: Project return results.every(Boolean); } -async function materializeAgents(projectPath: string, config: ProjectConfig): Promise { - const agentDirectory = path.join(projectPath, '.opencode', 'agent'); - await mkdir(agentDirectory, { recursive: true }); - await Promise.all(config.agents.map(async (agent) => { - await writeFile(path.join(agentDirectory, `${agent.id}.md`), buildAgentMarkdown(config, agent), 'utf8'); +async function removeRetiredGeneratedAgents( + projectPath: string, + previousConfig: ProjectConfig, + desiredManifest: ProjectAgentManifest, +): Promise { + if (!previousConfig.initialized) return; + const desiredPaths = new Set(desiredManifest.entries.map((entry) => entry.relativePath)); + const retiredEntries = buildProjectAgentManifest(previousConfig).entries + .filter((entry) => !desiredPaths.has(entry.relativePath)); + await Promise.all(retiredEntries.map(async (entry) => { + const filePath = path.join(projectPath, '.opencode', entry.relativePath); + try { + const existing = await readFile(filePath, 'utf8'); + if (sha256(existing) === entry.contentHash) { + await unlink(filePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + })); +} + +async function materializeAgents( + projectPath: string, + config: ProjectConfig, + previousConfig?: ProjectConfig, +): Promise { + const manifest = buildProjectAgentManifest(config); + if (previousConfig) { + await removeRetiredGeneratedAgents(projectPath, previousConfig, manifest); + } + if (manifest.entries.length === 0) return; + await mkdir(path.join(projectPath, '.opencode', 'agent'), { recursive: true }); + await Promise.all(manifest.entries.map(async (entry) => { + await writeFile(path.join(projectPath, '.opencode', entry.relativePath), entry.content, 'utf8'); })); } @@ -349,7 +425,7 @@ export async function createInitialProjectConfig( } export async function writeProjectConfig(projectPath: string, value: unknown): Promise { - const previous = await readProjectConfig(projectPath); + const previous = await readProjectConfigSnapshot(projectPath); if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid'); const requestedProjectType = value && typeof value === 'object' && !Array.isArray(value) ? (value as { projectType?: unknown }).projectType @@ -369,7 +445,7 @@ export async function writeProjectConfig(projectPath: string, value: unknown): P if (validationErrors.length > 0) { throw new Error(`Invalid project contact configuration: ${validationErrors.join(', ')}`); } - await materializeAgents(projectPath, config); + await materializeAgents(projectPath, config, previous.config); } await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8'); return config; diff --git a/electron/opencode/runtime-config-readiness.ts b/electron/opencode/runtime-config-readiness.ts new file mode 100644 index 0000000..01e9a51 --- /dev/null +++ b/electron/opencode/runtime-config-readiness.ts @@ -0,0 +1,107 @@ +export interface RuntimeConfigGenerationManager { + getRuntimeGeneration?: () => number; + getRuntimeGenerationProvenance?: () => 'unknown' | 'starting' | 'fresh' | 'attached'; +} + +interface RuntimeConfigPendingLatch { + runtimeGeneration: number; + clearOnFreshGeneration: boolean; +} + +const pendingLatches = new WeakMap(); +const coordinatorTails = new WeakMap>(); + +export interface RuntimeConfigCoordinatorLease { + isActive: () => boolean; + isRefreshPending: () => boolean; + markRefreshPending: () => number; + retainRefreshPending: () => number; +} + +function pendingForCurrentGeneration(manager: RuntimeConfigGenerationManager): boolean { + const latch = pendingLatches.get(manager as object); + if (!latch) return false; + const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; + const provenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown'; + if ( + latch.clearOnFreshGeneration + && runtimeGeneration !== latch.runtimeGeneration + && provenance === 'fresh' + ) { + pendingLatches.delete(manager as object); + return false; + } + return true; +} + +export async function withRuntimeConfigCoordinator( + manager: RuntimeConfigGenerationManager, + operation: (lease: RuntimeConfigCoordinatorLease) => Promise, +): Promise { + const key = manager as object; + const previous = coordinatorTails.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.then(() => current); + coordinatorTails.set(key, queued); + await previous; + let active = true; + try { + return await operation({ + isActive: () => active, + isRefreshPending: () => active ? pendingForCurrentGeneration(manager) : true, + markRefreshPending: () => { + const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; + if (active) pendingLatches.set(key, { runtimeGeneration, clearOnFreshGeneration: true }); + return runtimeGeneration; + }, + retainRefreshPending: () => { + const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; + if (active) pendingLatches.set(key, { runtimeGeneration, clearOnFreshGeneration: false }); + return runtimeGeneration; + }, + }); + } finally { + active = false; + release(); + if (coordinatorTails.get(key) === queued) coordinatorTails.delete(key); + } +} + +export async function markRuntimeConfigRefreshPending( + manager: RuntimeConfigGenerationManager, +): Promise { + return await withRuntimeConfigCoordinator(manager, async (lease) => lease.markRefreshPending()); +} + +export async function isRuntimeConfigRefreshPending( + manager: RuntimeConfigGenerationManager, +): Promise { + return await withRuntimeConfigCoordinator(manager, async (lease) => lease.isRefreshPending()); +} + +export async function withRuntimeAcceptanceTimeout( + operation: (signal: AbortSignal) => Promise, + timeoutMs = 10_000, +): Promise { + const controller = new AbortController(); + let timeout!: ReturnType; + let timeoutFallback: ReturnType | undefined; + const timeoutError = new Error('OpenCode runtime acceptance timed out'); + const timedOut = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + controller.abort(timeoutError); + timeoutFallback = setTimeout(() => reject(timeoutError), 0); + }, timeoutMs); + }); + try { + controller.signal.throwIfAborted(); + const accepted = operation(controller.signal); + return await Promise.race([accepted, timedOut]); + } finally { + clearTimeout(timeout); + if (timeoutFallback) clearTimeout(timeoutFallback); + } +} diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 8e604a2..0bcc6b3 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -202,6 +202,7 @@ export function ProvidersSettings() { const [editingProvider, setEditingProvider] = useState(null); const [syncingUserModels, setSyncingUserModels] = useState(false); const [autoUserModelSyncAttempted, setAutoUserModelSyncAttempted] = useState(false); + const [runtimeRefreshRequired, setRuntimeRefreshRequired] = useState(false); const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); const existingVendorIds = new Set(accounts.map((account) => account.vendorId)); const displayProviders = useMemo( @@ -214,7 +215,10 @@ export function ProvidersSettings() { refreshProviderSnapshot(); }, [refreshProviderSnapshot]); - const handleImportUserModelConfig = useCallback(async (options: { silent?: boolean } = {}) => { + const handleImportUserModelConfig = useCallback(async (options: { + silent?: boolean; + runtimeRefresh: 'apply' | 'defer'; + }) => { if (!accessToken) { if (!options.silent) { toast.error(t('aiProviders.toast.loginRequiredForSync', 'Sign in before syncing Makelore models.')); @@ -224,7 +228,12 @@ export function ProvidersSettings() { setSyncingUserModels(true); try { - await importUserModelConfig(accessToken); + const result = await importUserModelConfig(accessToken, { + runtimeRefresh: options.runtimeRefresh, + }); + setRuntimeRefreshRequired( + options.runtimeRefresh === 'defer' && result.runtimeRefreshRequired, + ); if (!options.silent) { toast.success(t('aiProviders.toast.syncedUserModels', 'Synced Makelore models')); } @@ -244,7 +253,7 @@ export function ProvidersSettings() { return; } setAutoUserModelSyncAttempted(true); - void handleImportUserModelConfig({ silent: true }); + void handleImportUserModelConfig({ silent: true, runtimeRefresh: 'defer' }); }, [ accessToken, autoUserModelSyncAttempted, @@ -324,7 +333,7 @@ export function ProvidersSettings() { + {runtimeRefreshRequired ? ( +

+ 模型列表已更新。准备好重启运行时后,点击“重新拉取并应用模型配置”。 +

+ ) : null}

已通过的模型

{approvedModels.map((model) => ( diff --git a/src/stores/opencode-session-run-machine.ts b/src/stores/opencode-session-run-machine.ts index e34a95d..67daa76 100644 --- a/src/stores/opencode-session-run-machine.ts +++ b/src/stores/opencode-session-run-machine.ts @@ -143,6 +143,7 @@ export function transitionSessionRunState(); const activeProjectEventSources = new Map(); @@ -277,6 +279,11 @@ function removeEventListenerIfSupported( let projectEventReconnectTimer: ReturnType | null = null; let projectEventReconnectAttempt = 0; const activeSessionRunTokens = new Map(); +const activePromptStartWatchdogs = new Map; + reject: (reason: Error) => void; +}>(); const pendingSessionRunStreamBatches = new Map(); const drainingAbortedSessionRunIds = new Map(); const ignoredSuppressedPolledAbortKeys = new Map }>(); @@ -307,6 +314,58 @@ type PendingSessionRunStreamBatch = { timer: ReturnType | null; }; +class PromptStartWatchdogCancelledError extends Error {} + +function rejectPromptStartWatchdog( + sessionId: string, + runToken: number, + reason: Error, +): void { + const watchdog = activePromptStartWatchdogs.get(sessionId); + if (!watchdog || watchdog.runToken !== runToken) return; + activePromptStartWatchdogs.delete(sessionId); + clearTimeout(watchdog.timer); + watchdog.reject(reason); +} + +function cancelPromptStartWatchdog(sessionId: string, runToken: number): void { + rejectPromptStartWatchdog( + sessionId, + runToken, + new PromptStartWatchdogCancelledError(), + ); +} + +function startPromptStartWatchdog(sessionId: string, runToken: number): Promise { + const existing = activePromptStartWatchdogs.get(sessionId); + if (existing) cancelPromptStartWatchdog(sessionId, existing.runToken); + + let rejectWatchdog!: (reason: Error) => void; + const promise = new Promise((_resolve, reject) => { + rejectWatchdog = reject; + }); + void promise.catch(() => undefined); + const timer = setTimeout(() => { + rejectPromptStartWatchdog( + sessionId, + runToken, + new Error(PROMPT_START_UNCONFIRMED_ERROR), + ); + }, PROMPT_START_CONFIRMATION_TIMEOUT_MS); + activePromptStartWatchdogs.set(sessionId, { + runToken, + timer, + reject: rejectWatchdog, + }); + return promise; +} + +function cancelAllPromptStartWatchdogs(): void { + for (const [sessionId, watchdog] of activePromptStartWatchdogs) { + cancelPromptStartWatchdog(sessionId, watchdog.runToken); + } +} + function beginSessionRun(sessionId: string): number { sessionRunNonce += 1; const nextToken = sessionRunNonce; @@ -316,6 +375,10 @@ function beginSessionRun(sessionId: string): number { function invalidateSessionRun(sessionId: string): void { ignoredSuppressedPolledAbortKeys.delete(sessionId); + const activeRunToken = activeSessionRunTokens.get(sessionId); + if (activeRunToken !== undefined) { + cancelPromptStartWatchdog(sessionId, activeRunToken); + } beginSessionRun(sessionId); } @@ -324,6 +387,7 @@ function isSessionRunCurrent(sessionId: string, token: number): boolean { } function closeActiveSessionEventSource(): void { + cancelAllPromptStartWatchdogs(); activeSessionRunTokens.clear(); for (const sessionId of pendingSessionRunStreamBatches.keys()) { cancelSessionRunStreamBatch(sessionId); @@ -2426,7 +2490,6 @@ function finishSessionRunSuccessfully( sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, - ...errorState(null), ...(completedTranscript && completedTranscript !== currentTranscript ? { sessionTranscriptBySessionId: { @@ -2552,7 +2615,6 @@ async function runSessionSubmission( ...clearSessionStreamingPatch(state, sessionId), ...runStatePatch, selectedSessionId, - ...errorState(null), sendingSessionIds, sendingSessionId: selectedSessionId === sessionId ? sessionId : state.sendingSessionId, sessionStatuses: { @@ -2571,6 +2633,42 @@ async function runSessionSubmission( }); let liveSessionError: Error | null = null; + let promptStartAcknowledged = submission.kind !== 'prompt'; + let promptStartWatchdog: Promise | null = null; + const acknowledgePromptStart = () => { + if (promptStartAcknowledged) return; + promptStartAcknowledged = true; + cancelPromptStartWatchdog(sessionId, runToken); + }; + const awaitDuringPromptStart = async (operation: Promise): Promise => { + if (promptStartAcknowledged || !promptStartWatchdog) return await operation; + try { + return await Promise.race([operation, promptStartWatchdog]); + } catch (error) { + if (error instanceof PromptStartWatchdogCancelledError && promptStartAcknowledged) { + return await operation; + } + throw error; + } + }; + const dispatchObservedIdle = (state: OpencodeState) => { + const runState = getSessionRunStateForSession(state, sessionId); + if (submission.kind === 'prompt' || runState.phase !== 'posting') { + return dispatchSessionRunEvent(state, sessionId, { + type: 'remote_idle', + runId: runToken, + }); + } + const startedPatch = dispatchSessionRunEvent(state, sessionId, { + type: 'remote_busy', + runId: runToken, + }); + return dispatchSessionRunEvent( + { ...state, ...startedPatch }, + sessionId, + { type: 'remote_idle', runId: runToken }, + ); + }; try { try { @@ -2586,20 +2684,32 @@ async function runSessionSubmission( if (!isSessionRunCurrent(sessionId, runToken)) return; if (getOpencodeEventSessionId(payload) !== sessionId) return; const status = normalizeSessionStatus(payload.status); + const acknowledgesStart = status.type === 'busy' || status.type === 'retry'; + if (acknowledgesStart) acknowledgePromptStart(); if (status.type === 'idle') { flushSessionRunStreamBatch(set, sessionId, runToken); } - set((state) => ({ - ...getSessionTranscriptPatch(state, 'session.status', payload), - ...dispatchSessionRunEvent(state, sessionId, { - type: status.type === 'idle' ? 'remote_idle' : 'remote_busy', - runId: runToken, - }), - sessionStatuses: { - ...state.sessionStatuses, - [sessionId]: status, - }, - })); + set((state) => { + const runState = getSessionRunStateForSession(state, sessionId); + const ignoreUnconfirmedIdle = submission.kind === 'prompt' + && status.type === 'idle' + && runState.phase === 'posting'; + return { + ...getSessionTranscriptPatch(state, 'session.status', payload), + ...(status.type === 'idle' + ? dispatchObservedIdle(state) + : dispatchSessionRunEvent(state, sessionId, { + type: 'remote_busy', + runId: runToken, + })), + sessionStatuses: { + ...state.sessionStatuses, + [sessionId]: ignoreUnconfirmedIdle + ? state.sessionStatuses[sessionId] ?? { type: 'busy' } + : status, + }, + }; + }); }); registerSessionRunEventListener(sessionId, source, 'session.idle', (event) => { @@ -2607,17 +2717,21 @@ async function runSessionSubmission( if (!isSessionRunCurrent(sessionId, runToken)) return; if (getOpencodeEventSessionId(payload) !== sessionId) return; flushSessionRunStreamBatch(set, sessionId, runToken); - set((state) => ({ - ...getSessionTranscriptPatch(state, 'session.idle', payload), - ...dispatchSessionRunEvent(state, sessionId, { - type: 'remote_idle', - runId: runToken, - }), - sessionStatuses: { - ...state.sessionStatuses, - [sessionId]: IDLE_SESSION_STATUS, - }, - })); + set((state) => { + const runState = getSessionRunStateForSession(state, sessionId); + const ignoreUnconfirmedIdle = submission.kind === 'prompt' + && runState.phase === 'posting'; + return { + ...getSessionTranscriptPatch(state, 'session.idle', payload), + ...dispatchObservedIdle(state), + sessionStatuses: { + ...state.sessionStatuses, + [sessionId]: ignoreUnconfirmedIdle + ? state.sessionStatuses[sessionId] ?? { type: 'busy' } + : IDLE_SESSION_STATUS, + }, + }; + }); }); registerSessionRunEventListener(sessionId, source, 'session.compacted', (event) => { @@ -2674,7 +2788,6 @@ async function runSessionSubmission( return { ...transcriptPatch, ...runStatePatch, - ...errorState(null), }; } const sendingSessionIds = withoutKey(state.sendingSessionIds, sessionId); @@ -2703,7 +2816,6 @@ async function runSessionSubmission( sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, - ...errorState(null), }; }); if (shouldSuppress) return; @@ -2713,6 +2825,7 @@ async function runSessionSubmission( } liveSessionError = new Error(message); + rejectPromptStartWatchdog(sessionId, runToken, liveSessionError); closeSessionEventSource(sessionId); set((state) => { const transcriptPatch = getSessionTranscriptPatch(state, 'session.error', payload); @@ -2744,7 +2857,6 @@ async function runSessionSubmission( sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, - ...errorState(message), }; }); }); @@ -2754,7 +2866,12 @@ async function runSessionSubmission( if (!isSessionRunCurrent(sessionId, runToken)) return; const question = normalizeQuestionRequest(payload); if (!question || question.sessionID !== sessionId) return; + acknowledgePromptStart(); set((state) => ({ + ...dispatchSessionRunEvent(state, sessionId, { + type: 'remote_busy', + runId: runToken, + }), pendingQuestions: mergePendingQuestion(state.pendingQuestions, question), })); }); @@ -2794,7 +2911,12 @@ async function runSessionSubmission( if (!isSessionRunCurrent(sessionId, runToken)) return; const permission = normalizePermissionRequest(payload); if (!permission || permission.sessionID !== sessionId) return; + acknowledgePromptStart(); set((state) => ({ + ...dispatchSessionRunEvent(state, sessionId, { + type: 'remote_busy', + runId: runToken, + }), pendingPermissions: mergePendingPermission(state.pendingPermissions, permission), })); }); @@ -2803,6 +2925,13 @@ async function runSessionSubmission( const payload = JSON.parse((event as MessageEvent).data) as Record; if (!isSessionRunCurrent(sessionId, runToken)) return; if (getOpencodeEventSessionId(payload) !== sessionId) return; + if (createStreamingMessageFromEvent(payload)?.role === 'assistant') { + acknowledgePromptStart(); + set((state) => dispatchSessionRunEvent(state, sessionId, { + type: 'remote_busy', + runId: runToken, + })); + } queueSessionRunStreamEvent(set, sessionId, runToken, 'message.updated', payload); }); @@ -2849,6 +2978,9 @@ async function runSessionSubmission( if (!response.success) { throw new Error(response.error || `Failed to start ${submission.kind}`); } + if (submission.kind === 'prompt' && !promptStartAcknowledged && !liveSessionError) { + promptStartWatchdog = startPromptStartWatchdog(sessionId, runToken); + } try { options?.onHostAccepted?.(); } catch { @@ -2857,6 +2989,10 @@ async function runSessionSubmission( if (liveSessionError) { throw liveSessionError; } + set((state) => dispatchSessionRunEvent(state, sessionId, { + type: submission.kind === 'prompt' ? 'post_accepted' : 'remote_busy', + runId: runToken, + })); if (submission.completion === 'post-success') { if (!isSessionRunCurrent(sessionId, runToken)) { return getSessionMessagesForState(get(), sessionId); @@ -2880,11 +3016,6 @@ async function runSessionSubmission( if (!isSessionRunCurrent(sessionId, runToken)) { return getSessionMessagesForState(get(), sessionId); } - set((state) => dispatchSessionRunEvent(state, sessionId, { - type: 'post_accepted', - runId: runToken, - })); - let messages: RawMessage[] = getSessionMessagesForState(get(), sessionId); let status: OpencodeSessionStatus = { type: 'busy' }; const useBaselineInference = submission.kind !== 'prompt'; @@ -2914,7 +3045,9 @@ async function runSessionSubmission( || eventStatus?.type === 'idle'; let statuses: OpencodeSessionStatusMap = {}; if (shouldPollStatus) { - const statusResponse = await hostApiFetch('/api/opencode/sessions/status'); + const statusResponse = await awaitDuringPromptStart( + hostApiFetch('/api/opencode/sessions/status'), + ); statuses = normalizeSessionStatusMap(statusResponse.statuses); nextStatusFallbackPollAt = Date.now() + SESSION_STATUS_FALLBACK_POLL_MS; } @@ -2926,7 +3059,7 @@ async function runSessionSubmission( || observedStatus?.type === 'idle'; let refreshedMessages = messages; if (shouldRefreshMessages) { - refreshedMessages = await fetchSessionMessages(sessionId); + refreshedMessages = await awaitDuringPromptStart(fetchSessionMessages(sessionId)); nextMessageFallbackPollAt = Date.now() + SESSION_MESSAGE_FALLBACK_POLL_MS; } if (liveSessionError) { @@ -2952,6 +3085,13 @@ async function runSessionSubmission( : sendingUserMessage ? getAssistantErrorAfterPrompt(refreshedMessages, sendingUserMessage) : null; + const assistantResponded = submission.kind === 'prompt' + && sendingUserMessage + && hasAssistantResponseAfterPrompt(refreshedMessages, sendingUserMessage); + const explicitBusy = httpStatus?.type === 'busy' || httpStatus?.type === 'retry'; + if (explicitBusy || assistantResponded) { + acknowledgePromptStart(); + } const inferredStatus = useBaselineInference ? inferMissingPolledStatusAfterBaseline(refreshedMessages, baseline) : sendingUserMessage @@ -2960,13 +3100,16 @@ async function runSessionSubmission( // A missing HTTP status is common while the runtime is transitioning. // Prefer a newly observed assistant response over the optimistic/event // busy marker, otherwise a completed run can remain busy forever. - const polledStatus = submission.kind === 'compact' + const observedPolledStatus = submission.kind === 'compact' ? shouldPollStatus ? httpStatus ?? eventStatus ?? status : eventStatus ?? status : shouldPollStatus ? httpStatus ?? inferredStatus : eventStatus ?? status ?? inferredStatus; + const polledStatus = submission.kind === 'prompt' && !promptStartAcknowledged + ? { type: 'busy' as const } + : observedPolledStatus; status = assistantError || assistantAborted ? IDLE_SESSION_STATUS : polledStatus; const currentMessages = getSessionMessagesForState(get(), sessionId); messages = assistantAborted || status.type === 'idle' @@ -2996,7 +3139,6 @@ async function runSessionSubmission( ...statuses, [sessionId]: polledStatus, }, - ...errorState(null), }; } @@ -3026,13 +3168,12 @@ async function runSessionSubmission( sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, - ...errorState(null), }; }); if (shouldSuppressAbort) { rememberIgnoredSuppressedPolledAbortKey(sessionId, runToken, assistantAbortKey); - await sleep(POLL_INTERVAL_MS); + await awaitDuringPromptStart(sleep(POLL_INTERVAL_MS)); continue; } @@ -3045,6 +3186,12 @@ async function runSessionSubmission( if (shouldRefreshMessages || shouldPollStatus || assistantError) { set((state) => ({ ...getSessionMessagePatch(state, sessionId, messages), + ...(promptStartAcknowledged + ? dispatchSessionRunEvent(state, sessionId, { + type: 'remote_busy', + runId: runToken, + }) + : {}), ...(assistantError ? dispatchSessionRunEvent(state, sessionId, { type: 'remote_failed', @@ -3057,7 +3204,6 @@ async function runSessionSubmission( ...statuses, [sessionId]: status, }, - ...errorState(assistantError ?? null), })); } @@ -3087,7 +3233,7 @@ async function runSessionSubmission( return messages; } - await sleep(POLL_INTERVAL_MS); + await awaitDuringPromptStart(sleep(POLL_INTERVAL_MS)); } } catch (error) { if (!isSessionRunCurrent(sessionId, runToken)) { @@ -3132,12 +3278,13 @@ async function runSessionSubmission( sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, - ...errorState(errorMessage), }; }); invalidateSessionRun(sessionId); clearDrainingAbortedRunIfForPreviousRun(sessionId, runToken); throw error; + } finally { + cancelPromptStartWatchdog(sessionId, runToken); } } @@ -3653,7 +3800,6 @@ export const useOpencodeStore = create((set, get) => ({ sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: true, - ...errorState(null), }; }); try { @@ -3692,7 +3838,6 @@ export const useOpencodeStore = create((set, get) => ({ return { ...clearSessionRunAbortSuppression(state, sessionId), loading: false, - ...errorState(null), }; } @@ -3715,7 +3860,6 @@ export const useOpencodeStore = create((set, get) => ({ sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, - ...errorState(null), }; }); } catch (error) { @@ -3731,7 +3875,6 @@ export const useOpencodeStore = create((set, get) => ({ }) : clearSessionRunAbortSuppression(state, sessionId)), loading: false, - ...errorState(errorMessage), })); throw error; } @@ -4249,7 +4392,6 @@ export const useOpencodeStore = create((set, get) => ({ return { ...getSessionMessagePatch(currentState, sessionId, messages), ...runStatePatch, - ...errorState(null), }; }); try { diff --git a/src/stores/providers.ts b/src/stores/providers.ts index 9d3a9c7..3ed189a 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -38,9 +38,13 @@ interface ProviderState { refreshProviderSnapshot: () => Promise; createAccount: (account: ProviderAccount, apiKey?: string) => Promise; removeAccount: (accountId: string) => Promise; - importUserModelConfig: (accessToken: string) => Promise<{ + importUserModelConfig: ( + accessToken: string, + options?: { runtimeRefresh?: 'apply' | 'defer' }, + ) => Promise<{ account: ProviderAccount; importedModels: string[]; + runtimeRefreshRequired: boolean; }>; validateAccountApiKey: ( accountId: string, @@ -218,16 +222,20 @@ export const useProviderStore = create((set, get) => ({ deleteAccount: async (accountId) => get().removeAccount(accountId), - importUserModelConfig: async (accessToken) => { + importUserModelConfig: async (accessToken, options) => { try { const result = await hostApiFetch<{ success: boolean; error?: string; account?: ProviderAccount; importedModels?: string[]; + runtimeRefreshRequired?: boolean; }>('/api/provider-accounts/import-user-model-config', { method: 'POST', - body: JSON.stringify({ accessToken }), + body: JSON.stringify({ + accessToken, + runtimeRefresh: options?.runtimeRefresh ?? 'apply', + }), }); if (!result.success || !result.account) { @@ -238,6 +246,7 @@ export const useProviderStore = create((set, get) => ({ return { account: result.account, importedModels: result.importedModels ?? [], + runtimeRefreshRequired: result.runtimeRefreshRequired === true, }; } catch (error) { console.error('Failed to import current user model config:', error); diff --git a/tests/e2e/opencode-multichat-runtime.spec.ts b/tests/e2e/opencode-multichat-runtime.spec.ts new file mode 100644 index 0000000..cc3796b --- /dev/null +++ b/tests/e2e/opencode-multichat-runtime.spec.ts @@ -0,0 +1,339 @@ +import type { Page } from '@playwright/test'; +import type { ElectronApplication } from 'playwright-core'; +import { expect, getStableWindow, test } from './fixtures/electron'; + +type CapturedRequest = { + path: string; + method: string; + body?: Record; +}; + +const SESSION_A_ID = 'ses_multichat_a'; +const SESSION_B_ID = 'ses_multichat_b'; +const REGISTRY_PENDING_ERROR = '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。'; + +async function disableRendererEventSource(page: Page): Promise { + await page.addInitScript(() => { + class DisabledEventSource extends EventTarget { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + readonly CONNECTING = DisabledEventSource.CONNECTING; + readonly OPEN = DisabledEventSource.OPEN; + readonly CLOSED = DisabledEventSource.CLOSED; + readonly url: string; + readonly withCredentials = false; + readyState = DisabledEventSource.OPEN; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string) { + super(); + this.url = url; + } + + close(): void { + this.readyState = DisabledEventSource.CLOSED; + } + } + + Object.defineProperty(window, 'EventSource', { + configurable: true, + writable: true, + value: DisabledEventSource, + }); + }); +} + +async function installMultichatHost(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(async () => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + const sessionAId = 'ses_multichat_a'; + const sessionBId = 'ses_multichat_b'; + const registryPendingError = '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。'; + type MainState = { + captured: CapturedRequest[]; + sessionABusy: boolean; + }; + const mainGlobal = globalThis as typeof globalThis & { + __makeloreMultichatE2EState?: MainState; + }; + const state: MainState = { + captured: [], + sessionABusy: false, + }; + mainGlobal.__makeloreMultichatE2EState = state; + + const now = '2026-08-17T10:00:00.000Z'; + const project = { + id: 'prj_multichat_e2e', + path: 'D:/e2e/multichat', + name: 'multichat', + createdAt: now, + updatedAt: now, + lastOpenedAt: now, + }; + const agent = { + id: 'game-development', + avatarId: 'avatar-01', + roleName: '游戏开发', + name: '游戏开发', + builtIn: false, + enabled: true, + model: 'niancode-user-models/qwen3.7-plus', + skillIds: [], + responsibility: { + mission: '实现并验证项目功能。', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + }; + const config = { + schemaVersion: 1, + projectType: 'custom', + initialized: true, + defaultModel: 'niancode-user-models/qwen3.7-plus', + agents: [agent], + knowledgeDirectory: 'knowledge', + createdAt: now, + updatedAt: now, + }; + const sessions = [ + { id: sessionAId, title: 'Session A', agent: agent.id, time: { updated: 2 } }, + { id: sessionBId, title: 'Session B', agent: agent.id, time: { updated: 1 } }, + ]; + const conversationState = { + schemaVersion: 1, + sessions: [ + { + sessionId: sessionAId, + agentId: agent.id, + archivedAt: null, + unreadCount: 0, + createdAt: now, + updatedAt: '2026-08-17T10:00:02.000Z', + }, + { + sessionId: sessionBId, + agentId: agent.id, + archivedAt: null, + unreadCount: 0, + createdAt: now, + updatedAt: '2026-08-17T10:00:01.000Z', + }, + ], + updatedAt: '2026-08-17T10:00:02.000Z', + }; + const runtimeStatus = { + state: 'running', + port: 4096, + url: 'http://127.0.0.1:4096', + }; + const respond = (json: unknown, responseStatus = 200) => ({ + ok: true, + data: { + status: responseStatus, + ok: responseStatus >= 200 && responseStatus < 300, + json, + }, + }); + + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async ( + _event, + request: { path?: string; method?: string; body?: string | null }, + ) => { + const path = request.path ?? ''; + const method = request.method ?? 'GET'; + const body = request.body + ? JSON.parse(request.body) as Record + : undefined; + state.captured.push({ path, method, ...(body ? { body } : {}) }); + + if (path === '/api/opencode/status') return respond(runtimeStatus); + if (path === '/api/opencode/health') { + return respond({ ok: true, status: runtimeStatus }); + } + if (path === '/api/opencode/projects' || path.startsWith('/api/opencode/projects?')) { + return respond({ projects: [project], activeProject: project }); + } + if (path === '/api/opencode/projects/active' && method === 'GET') { + return respond({ projects: [project], activeProject: project }); + } + if (path.startsWith('/api/opencode/projects/config?')) { + return respond({ status: 'valid', config, knowledgeFiles: [] }); + } + if (path.startsWith('/api/opencode/projects/template?')) { + return respond({ status: 'missing' }); + } + if (path.startsWith('/api/opencode/projects/conversations?')) { + return respond({ state: conversationState }); + } + if (path === '/api/opencode/projects/conversations' && method === 'POST') { + return respond({ success: true, state: conversationState }); + } + if (path === '/api/opencode/config-summary') { + return respond({ + model: 'niancode-user-models/qwen3.7-plus', + smallModel: null, + providerIds: ['niancode-user-models'], + enabledProviderIds: ['niancode-user-models'], + providerCount: 1, + }); + } + if (path === '/api/provider-accounts') { + return respond([{ + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + model: 'qwen3.7-plus', + enabled: true, + isDefault: true, + createdAt: now, + updatedAt: now, + }]); + } + if (path === '/api/provider-accounts/key-info') { + return respond([{ accountId: 'niancode-user-models', hasKey: true, keyMasked: 'sk-***' }]); + } + if (path === '/api/provider-vendors') return respond([]); + if (path === '/api/provider-accounts/default') { + return respond({ accountId: 'niancode-user-models' }); + } + if (path === '/api/opencode/sessions') return respond({ sessions }); + if (path === '/api/opencode/sessions/status') { + return respond({ + statuses: { + [sessionAId]: { type: state.sessionABusy ? 'busy' : 'idle' }, + [sessionBId]: { type: 'idle' }, + }, + }); + } + if ( + (path === `/api/opencode/sessions/${sessionAId}/messages` + || path === `/api/opencode/sessions/${sessionBId}/messages`) + && method === 'GET' + ) { + return respond({ messages: [] }); + } + if (path === `/api/opencode/sessions/${sessionAId}/messages` && method === 'POST') { + state.sessionABusy = true; + return respond({ success: true }, 202); + } + if (path === `/api/opencode/sessions/${sessionBId}/messages` && method === 'POST') { + return respond({ + success: false, + error: registryPendingError, + code: 'OPENCODE_AGENT_REGISTRY_PENDING', + promptSent: false, + terminal: true, + retryable: false, + runtimeGeneration: 1, + }, 409); + } + if (path.endsWith('/todos')) return respond({ todos: [] }); + if (path.endsWith('/diff')) return respond({ diffs: [] }); + if (path === '/api/opencode/questions') return respond({ questions: [] }); + if (path === '/api/opencode/permissions') return respond({ permissions: [] }); + if (path === '/api/opencode/files/status') return respond({ files: [] }); + if (path === '/api/opencode/commands') { + return respond({ commands: [], shareEnabled: true }); + } + if (path === '/api/opencode/skills') return respond({ skills: [] }); + throw new Error(`Unexpected hostapi request: ${method} ${path}`); + }); + }); +} + +async function capturedRequests(electronApp: ElectronApplication): Promise { + return await electronApp.evaluate(() => { + const mainGlobal = globalThis as typeof globalThis & { + __makeloreMultichatE2EState?: { captured: CapturedRequest[] }; + }; + return structuredClone(mainGlobal.__makeloreMultichatE2EState?.captured ?? []); + }); +} + +function sessionSelectionButton(page: Page, title: string) { + return page.getByRole('button', { name: `归档会话 ${title}`, exact: true }) + .locator('..') + .getByRole('button') + .first(); +} + +async function submitComposer(page: Page, text: string): Promise { + const composer = page.getByRole('textbox'); + await composer.fill(text); + const sendButton = page.getByRole('button', { name: '发送', exact: true }); + await expect(sendButton).toBeEnabled(); + await sendButton.click(); +} + +test('keeps Session A running when Session B is terminally rejected by Agent preflight', async ({ + launchElectronApp, +}) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + await installMultichatHost(electronApp); + + let page = await getStableWindow(electronApp); + await disableRendererEventSource(page); + await page.reload(); + page = await getStableWindow(electronApp); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('project-agent-chat-game-development').click(); + + const sessionA = sessionSelectionButton(page, 'Session A'); + const sessionB = sessionSelectionButton(page, 'Session B'); + await expect(sessionA).toBeVisible(); + await expect(sessionB).toBeVisible(); + + await sessionA.click(); + await expect(sessionA).toHaveAttribute('aria-current', 'page'); + await submitComposer(page, 'Keep Session A running'); + await expect.poll(async () => [...new Set((await capturedRequests(electronApp)) + .filter((request) => request.path.includes('/messages')) + .map((request) => `${request.method} ${request.path}`))]) + .toContain(`POST /api/opencode/sessions/${SESSION_A_ID}/messages`); + const stopButton = page.getByRole('button', { name: '停止当前对话' }); + await expect(stopButton).toBeEnabled(); + await expect(stopButton.getByTestId('opencode-agent-response-loader')).toBeVisible(); + + await sessionB.click(); + await submitComposer(page, 'Try Session B while A is busy'); + const errorBanner = page.getByTestId('opencode-error-banner'); + await expect(errorBanner).toContainText(REGISTRY_PENDING_ERROR); + await expect(page.getByRole('textbox')).toHaveValue('Try Session B while A is busy'); + await expect(page.getByRole('button', { name: '停止当前对话' })).toHaveCount(0); + await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'idle'); + + await expect.poll(async () => (await capturedRequests(electronApp)).map( + (request) => `${request.method} ${request.path}`, + )).toContain(`POST /api/opencode/sessions/${SESSION_B_ID}/messages`); + + // A rejected preflight deliberately keeps B's draft. Clear it so A's busy + // composer projects the stop control and its response loader. + await page.getByRole('textbox').fill(''); + await sessionA.click(); + await expect(errorBanner).toHaveCount(0); + await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'busy'); + await expect(page.getByRole('button', { name: '停止当前对话' })).toBeEnabled(); + await expect(page.getByTestId('opencode-agent-response-loader')).toBeVisible(); + + await sessionB.click(); + await expect(errorBanner).toContainText(REGISTRY_PENDING_ERROR); + await expect(page.getByTestId('opencode-chat-layout')).toHaveAttribute('data-session-status', 'idle'); + + const promptPosts = (await capturedRequests(electronApp)).filter((request) => ( + request.method === 'POST' && request.path.endsWith('/messages') + )); + expect(promptPosts.filter((request) => request.path.includes(SESSION_A_ID))).toHaveLength(1); + expect(promptPosts.filter((request) => request.path.includes(SESSION_B_ID))).toHaveLength(1); +}); diff --git a/tests/unit/login-page.test.tsx b/tests/unit/login-page.test.tsx index 83c8ecd..e87c2f4 100644 --- a/tests/unit/login-page.test.tsx +++ b/tests/unit/login-page.test.tsx @@ -464,7 +464,10 @@ describe('Login page', () => { await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', { method: 'POST', - body: JSON.stringify({ accessToken: 'fresh-access-token' }), + body: JSON.stringify({ + accessToken: 'fresh-access-token', + runtimeRefresh: 'apply', + }), }); }); }); diff --git a/tests/unit/makelore-character-scene.test.tsx b/tests/unit/makelore-character-scene.test.tsx index 5672a4a..71aa0b7 100644 --- a/tests/unit/makelore-character-scene.test.tsx +++ b/tests/unit/makelore-character-scene.test.tsx @@ -108,4 +108,64 @@ describe('Makelore character scene partner profiles', () => { fireEvent.click(screen.getByRole('button', { name: '返回技能列表' })); expect(await screen.findByTestId('skill-library-card-agent-browser')).toBeInTheDocument(); }); + + it('defers the automatic model refresh and applies it only after an explicit click', async () => { + const importUserModelConfig = vi.fn() + .mockResolvedValueOnce({ + account: { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + enabled: true, + isDefault: true, + createdAt: '2026-08-17T00:00:00.000Z', + updatedAt: '2026-08-17T00:00:00.000Z', + }, + importedModels: ['gpt-4.1-mini'], + runtimeRefreshRequired: true, + }) + .mockResolvedValueOnce({ + account: { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + enabled: true, + isDefault: true, + createdAt: '2026-08-17T00:00:00.000Z', + updatedAt: '2026-08-17T00:00:00.000Z', + }, + importedModels: ['gpt-4.1-mini'], + runtimeRefreshRequired: true, + }); + useAuthStore.setState({ + accessToken: 'access-token', + getValidAccessToken: vi.fn().mockResolvedValue('access-token'), + }); + useProviderStore.setState({ + refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined), + importUserModelConfig, + }); + + render(); + + fireEvent.click(screen.getByTestId('resource-card-models')); + + expect(await screen.findByTestId('makelore-runtime-refresh-pending')).toBeVisible(); + expect(importUserModelConfig).toHaveBeenNthCalledWith(1, 'access-token', { + runtimeRefresh: 'defer', + }); + + fireEvent.click(screen.getByTestId('makelore-model-config-refresh-button')); + + await waitFor(() => { + expect(importUserModelConfig).toHaveBeenNthCalledWith(2, 'access-token', { + runtimeRefresh: 'apply', + }); + }); + await waitFor(() => { + expect(screen.queryByTestId('makelore-runtime-refresh-pending')).not.toBeInTheDocument(); + }); + }); }); diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 083ce39..c27230a 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -496,6 +496,7 @@ function installCompressionChatHost(postResponse: Promise<{ ], }; } + if (path === '/api/opencode/sessions/ses_2/messages') return { messages: [] }; if (path.endsWith('/todos')) return { todos: [] }; throw new Error(`Unexpected path ${path}`); }); @@ -523,6 +524,7 @@ interface RunningSlashProjectOptions { extraSessions?: OpencodeSession[]; forkedSession?: OpencodeSession; busy?: boolean; + sessionStatuses?: Record; } function getTestSessionId(session: OpencodeSession): string { @@ -569,9 +571,13 @@ function installRunningSlashProject( sessions, sessionsByProjectId: { prj_slash: sessions }, selectedSessionId: 'ses_slash', - sessionStatuses: { - ses_slash: { type: busy ? 'busy' : 'idle' }, - }, + sessionStatuses: Object.fromEntries(sessions.map((session) => { + const sessionId = getTestSessionId(session); + return [ + sessionId, + options.sessionStatuses?.[sessionId] ?? { type: busy ? 'busy' : 'idle' }, + ]; + })), sessionMessages: messagesBySession.ses_slash, sessionMessagesBySessionId: messagesBySession, runtimeConfigSummary: { @@ -627,7 +633,8 @@ function installRunningSlashProject( return { statuses: Object.fromEntries(sessions.map((session) => [ getTestSessionId(session), - { type: busy ? 'busy' : 'idle' }, + options.sessionStatuses?.[getTestSessionId(session)] + ?? { type: busy ? 'busy' : 'idle' }, ])), }; } @@ -794,6 +801,7 @@ describe('OpencodeChatPanel', () => { sendingSessionId: null, sendingSessionIds: {}, queuedSessionPrompts: {}, + sessionRunStates: {}, pendingQuestions: [], pendingPermissions: [], sessionTodos: [], @@ -2595,6 +2603,130 @@ describe('OpencodeChatPanel', () => { expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/provider-accounts/import-user-model-config')).toBe(true); }); + it('defers automatic model sync and preserves session B when session A is busy and a restart is required', async () => { + const toastErrorSpy = vi.spyOn(toast, 'error'); + installRunningSlashProject({ + commands: [{ name: 'Review', hints: [] }], + extraSessions: [{ id: 'ses_second', title: 'Second' }], + messagesBySession: { ses_second: [] }, + sessionStatuses: { + ses_slash: { type: 'busy' }, + ses_second: { type: 'idle' }, + }, + }); + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + user: { + username: 'student', + userId: '42', + tenantId: 6, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + const originalImportUserModelConfig = useProviderStore.getState().importUserModelConfig; + const importUserModelConfig = vi.fn().mockResolvedValue({ + account: { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + model: 'gpt-5', + enabled: true, + isDefault: true, + createdAt: '2026-07-06T00:00:00.000Z', + updatedAt: '2026-07-06T00:00:00.000Z', + metadata: { + worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + }, + }, + importedModels: ['gpt-5'], + runtimeRefreshRequired: true, + }); + useProviderStore.setState({ + accounts: [{ + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + model: 'gpt-5', + enabled: true, + isDefault: true, + createdAt: '2026-07-06T00:00:00.000Z', + updatedAt: '2026-07-06T00:00:00.000Z', + metadata: { + worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + }, + }], + importUserModelConfig, + } as never); + useOpencodeStore.setState({ + selectedSessionId: 'ses_second', + sessionStatuses: { + ses_slash: { type: 'busy' }, + ses_second: { type: 'idle' }, + }, + sessionRunStates: { + ses_slash: { + phase: 'running', + runId: 1, + promptId: 'prompt-a', + queue: [], + terminalReason: null, + error: null, + suppressNextAbortError: false, + }, + }, + } as never); + + render(); + const textarea = await screen.findByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'session B draft' } }); + fireEvent.submit(screen.getByTestId('opencode-message-composer')); + + await waitFor(() => expect(importUserModelConfig).toHaveBeenCalledWith( + 'access-token', + { runtimeRefresh: 'defer' }, + )); + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/opencode/sessions/ses_second/messages' + && init?.method === 'POST' + ))).toBe(false); + expect(useOpencodeStore.getState().sessionStatuses.ses_slash).toEqual({ type: 'busy' }); + expect(textarea).toHaveValue('session B draft'); + expect(toastErrorSpy).toHaveBeenCalledWith( + '模型配置已更新,请手动重启运行时后重试。', + ); + + fireEvent.change(textarea, { target: { value: '/Review pending command' } }); + fireEvent.submit(screen.getByTestId('opencode-message-composer')); + await waitFor(() => expect(importUserModelConfig).toHaveBeenCalledTimes(2)); + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/opencode/sessions/ses_second/command' + && init?.method === 'POST' + ))).toBe(false); + expect(textarea).toHaveValue('/Review pending command'); + + fireEvent.change(textarea, { target: { value: '/summarize' } }); + fireEvent.submit(screen.getByTestId('opencode-message-composer')); + await waitFor(() => expect(importUserModelConfig).toHaveBeenCalledTimes(3)); + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/opencode/sessions/ses_second/summarize' + && init?.method === 'POST' + ))).toBe(false); + expect(textarea).toHaveValue('/summarize'); + expect(useOpencodeStore.getState().sessionStatuses.ses_slash).toEqual({ type: 'busy' }); + useProviderStore.setState({ + importUserModelConfig: originalImportUserModelConfig, + }); + }); + it('shows the selected partner model as read-only in the composer', async () => { const activeProject = { id: 'prj_1', @@ -4002,7 +4134,21 @@ describe('OpencodeChatPanel', () => { expect(screen.queryByTestId('opencode-upstream-saturated-notice')).not.toBeInTheDocument(); act(() => { - useOpencodeStore.setState({ error: saturatedMessage, errorKind: null }); + useOpencodeStore.setState({ + error: null, + errorKind: null, + sessionRunStates: { + ses_1: { + phase: 'idle', + runId: 1, + promptId: 'msg_saturated', + queue: [], + terminalReason: 'failed', + error: saturatedMessage, + suppressNextAbortError: false, + }, + }, + }); }); expect(screen.queryByTestId('opencode-error-banner')).not.toBeInTheDocument(); expect(screen.queryByTestId('opencode-upstream-saturated-notice')).not.toBeInTheDocument(); @@ -4013,6 +4159,81 @@ describe('OpencodeChatPanel', () => { expect(screen.getByTestId('opencode-error-banner')).toHaveTextContent('rate_limit_exceeded:请求过于频繁,请稍后重试'); }); + it('shows a run error only for its selected session while preserving global runtime errors', async () => { + installCompressionChatHost(Promise.resolve({ success: true })); + useOpencodeStore.setState({ + sessionMessagesBySessionId: { ses_1: [], ses_2: [] }, + }); + render(); + + const sidebar = await screen.findByTestId('agent-conversation-sidebar'); + const sessionBButton = within(sidebar).getByText('Other').closest('button'); + expect(sessionBButton).not.toBeNull(); + fireEvent.click(sessionBButton!); + await waitFor(() => { + expect(useOpencodeStore.getState().selectedSessionId).toBe('ses_2'); + }); + + act(() => { + useOpencodeStore.setState({ + sessionStatuses: { ses_1: { type: 'busy' }, ses_2: { type: 'idle' } }, + sendingSessionId: 'ses_1', + sendingSessionIds: { ses_1: true }, + error: null, + errorKind: null, + sessionRunStates: { + ses_2: { + phase: 'idle', + runId: 1, + promptId: 'msg_session_b', + queue: [], + terminalReason: 'failed', + error: 'Session B Agent registry is stale', + suppressNextAbortError: false, + }, + }, + }); + }); + expect(screen.getByTestId('opencode-error-banner')).toHaveTextContent( + 'Session B Agent registry is stale', + ); + + const sessionAButton = within(sidebar).getByText('Compression').closest('button'); + expect(sessionAButton).not.toBeNull(); + fireEvent.click(sessionAButton!); + await waitFor(() => { + expect(useOpencodeStore.getState().selectedSessionId).toBe('ses_1'); + }); + expect(screen.queryByTestId('opencode-error-banner')).not.toBeInTheDocument(); + + act(() => { + useOpencodeStore.setState({ + error: 'Session B Agent registry is stale', + errorKind: null, + }); + }); + expect(screen.getByTestId('opencode-error-banner')).toHaveTextContent( + 'Session B Agent registry is stale', + ); + + fireEvent.click(sessionBButton!); + await waitFor(() => { + expect(useOpencodeStore.getState().selectedSessionId).toBe('ses_2'); + }); + act(() => { + useOpencodeStore.setState({ + error: 'OpenCode runtime connection lost', + errorKind: null, + }); + }); + expect(screen.getByTestId('opencode-error-banner')).toHaveTextContent( + 'OpenCode runtime connection lost', + ); + expect(screen.getByTestId('opencode-error-banner')).not.toHaveTextContent( + 'Session B Agent registry is stale', + ); + }); + it('shows pending user decisions in the transcript header', async () => { const activeProject = { id: 'prj_1', @@ -5667,8 +5888,8 @@ describe('OpencodeChatPanel', () => { act(() => { useOpencodeStore.setState({ - error: 'Token balance exhausted (request id: 2026080810470193210007741833395)', - errorKind: 'quota_exhausted', + error: null, + errorKind: null, sessionRunStates: { ses_1: { phase: 'idle', @@ -5693,8 +5914,8 @@ describe('OpencodeChatPanel', () => { act(() => { useOpencodeStore.setState({ - error: 'Token quota exhausted for rolling 5-hour window', - errorKind: 'quota_exhausted', + error: null, + errorKind: null, sessionRunStates: { ses_1: { phase: 'idle', @@ -5720,8 +5941,8 @@ describe('OpencodeChatPanel', () => { useOpencodeStore.setState({ selectedSessionId: 'ses_2', sessionMessages: [], - error: 'Token balance exhausted (request id: background-request)', - errorKind: 'quota_exhausted', + error: null, + errorKind: null, sessionRunStates: { ses_1: { phase: 'idle', @@ -5887,8 +6108,10 @@ describe('OpencodeChatPanel', () => { }); await waitFor(() => { - expect(useOpencodeStore.getState().error).toBe('Host rejected prompt'); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe('Host rejected prompt'); }); + expect(useOpencodeStore.getState().error).toBeNull(); expect(composer).toHaveValue('Do not lose me'); expect(screen.getByTestId('opencode-composer-attachment')).toBeInTheDocument(); expect(screen.getByTestId('opencode-attachment-use-original')) diff --git a/tests/unit/opencode-client.test.ts b/tests/unit/opencode-client.test.ts index c41196a..f1def8e 100644 --- a/tests/unit/opencode-client.test.ts +++ b/tests/unit/opencode-client.test.ts @@ -113,6 +113,62 @@ describe('opencode client', () => { ); }); + it('lists the live Agent registry scoped to the selected folder', async () => { + const agents = [{ name: 'game-design', description: '游戏设计伙伴' }]; + const fetchImpl = vi.fn(async () => { + return new Response(JSON.stringify(agents), { + headers: { 'Content-Type': 'application/json' }, + }); + }); + const client = createOpencodeClient({ + baseUrl: 'http://127.0.0.1:4096', + directory: 'D:/work/app', + fetchImpl, + }); + + await expect(client.listAgents()).resolves.toEqual(agents); + expect(fetchImpl).toHaveBeenCalledWith( + 'http://127.0.0.1:4096/agent?directory=D%3A%2Fwork%2Fapp', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-opencode-directory': encodeURIComponent('D:/work/app'), + }), + }), + ); + }); + + it('forwards AbortSignal to bounded runtime config, Agent, prompt, command, and summarize requests', async () => { + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + const pathname = new URL(String(url)).pathname; + if (pathname === '/agent') return new Response('[]'); + if (pathname === '/config') return new Response('{}'); + if (pathname.endsWith('/prompt_async')) return new Response(null, { status: 204 }); + return new Response('{}'); + }); + const client = createOpencodeClient({ + baseUrl: 'http://127.0.0.1:4096', + directory: 'D:/work/app', + fetchImpl, + }); + const controller = new AbortController(); + const request = { signal: controller.signal }; + + await client.getConfig(request); + await client.listAgents(request); + await client.promptSessionAsync('ses_1', { text: 'Ship it' }, request); + await client.executeSessionCommand('ses_1', { command: 'review', arguments: '' }, request); + await client.summarizeSession('ses_1', { + providerID: 'niancode-user-models', + modelID: 'qwen3.7-plus', + }, request); + + expect(fetchImpl).toHaveBeenCalledTimes(5); + for (const [, init] of fetchImpl.mock.calls) { + expect(init?.signal).toBe(controller.signal); + } + }); + it('posts a text message to a session scoped to the selected folder', async () => { const fetchImpl = vi.fn(async () => { return new Response(JSON.stringify({ id: 'msg_1', role: 'user' }), { diff --git a/tests/unit/opencode-manager.test.ts b/tests/unit/opencode-manager.test.ts index da8b429..db12a19 100644 --- a/tests/unit/opencode-manager.test.ts +++ b/tests/unit/opencode-manager.test.ts @@ -105,6 +105,43 @@ describe('OpencodeManager', () => { expect(manager.getStatus()).not.toBe(status); }); + it('exposes a stable generation that rolls over only when a new runtime starts', async () => { + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: 4351, + binPath: 'C:\\NianCode\\opencode.exe', + findPortOwner: vi.fn().mockResolvedValue(null), + spawn, + }); + + expect(manager.getRuntimeGeneration()).toBe(0); + + const initialStart = manager.start(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + expect(manager.getRuntimeGeneration()).toBe(1); + expect(manager.getRuntimeGenerationProvenance()).toBe('starting'); + children[0].stdout.emit( + 'data', + Buffer.from('opencode server listening on http://127.0.0.1:4351\n'), + ); + await initialStart; + expect(manager.getRuntimeGeneration()).toBe(1); + expect(manager.getRuntimeGenerationProvenance()).toBe('fresh'); + expect(manager.getRuntimeGeneration()).toBe(1); + + const restart = manager.restart(); + children[0].emit('exit', 0); + await vi.waitFor(() => expect(children).toHaveLength(2)); + children[1].stdout.emit( + 'data', + Buffer.from('opencode server listening on http://127.0.0.1:4351\n'), + ); + await restart; + + expect(manager.getRuntimeGeneration()).toBe(2); + expect(manager.getRuntimeGenerationProvenance()).toBe('fresh'); + }); + it('falls back to an ephemeral port when the preferred port has an unhealthy listener', async () => { const { children, calls, spawn } = createSpawnHarness(); const preferredPort = 4340; @@ -429,6 +466,7 @@ describe('OpencodeManager', () => { port: 4329, url: 'http://127.0.0.1:4329', }); + expect(manager.getRuntimeGenerationProvenance()).toBe('attached'); expect(logWarn).toHaveBeenCalledWith( '[opencode-runtime] Attached to an existing server; newly generated provider config may not be active', expect.objectContaining({ diff --git a/tests/unit/opencode-routes.test.ts b/tests/unit/opencode-routes.test.ts index abe11f9..f6bdf94 100644 --- a/tests/unit/opencode-routes.test.ts +++ b/tests/unit/opencode-routes.test.ts @@ -5,12 +5,29 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleOpencodeRoutes } from '@electron/api/routes/opencode'; +import { handleProviderRoutes } from '@electron/api/routes/providers'; +import { + createInitialProjectConfig, + readProjectConfig, + writeProjectConfig, +} from '@electron/opencode/project-config'; +import { + mutateProjectAgentRuntime, +} from '@electron/opencode/project-agent-runtime'; +import { + isRuntimeConfigRefreshPending, + markRuntimeConfigRefreshPending, + withRuntimeConfigCoordinator, +} from '@electron/opencode/runtime-config-readiness'; import type { OpencodeProject } from '@electron/opencode/project-store'; import { clearWorksSquareSession, storeWorksSquareSession, } from '@electron/services/works-square-session'; -import { createProjectConfig } from '../../shared/project-config'; +import { + createProjectConfig, + type ProjectAgentConfig, +} from '../../shared/project-config'; const createOpencodeClientMock = vi.hoisted(() => vi.fn()); const decorateOpencodeRequestMock = vi.hoisted(() => vi.fn()); @@ -140,6 +157,42 @@ async function writeValidProjectConfig(projectPath: string) { return config; } +function createConfiguredAgent(overrides: Partial = {}): ProjectAgentConfig { + const now = '2026-07-11T00:00:00.000Z'; + return { + id: 'game-design', + avatarId: 'avatar-01', + roleName: '游戏设计伙伴', + name: '小明', + builtIn: false, + enabled: true, + model: 'openai/gpt-4o-mini', + skillIds: [], + responsibility: { + mission: '帮助用户完成游戏设计。', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +async function createConfiguredAgentProject(projectPath: string, agent = createConfiguredAgent()) { + const initial = await createInitialProjectConfig(projectPath); + return await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [agent], + }); +} + describe('opencode host api routes', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1651,36 +1704,1063 @@ description: Browser debugging. }); it('starts an async prompt through the active project directory', async () => { - const response = createResponse(); - const promptSessionAsync = vi.fn(async () => undefined); - createOpencodeClientMock.mockReturnValue({ promptSessionAsync }); + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-prompt-')); + try { + await createConfiguredAgentProject(projectPath); + const response = createResponse(); + const promptSessionAsync = vi.fn(async () => undefined); + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); - const handled = await handleOpencodeRoutes( - createRequest('POST', { text: 'Ship it', userContext: '当前用户叫小明', agent: 'game-design' }), - response.res, - new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), - { + const handled = await handleOpencodeRoutes( + createRequest('POST', { text: 'Ship it', userContext: '当前用户叫小明', agent: 'game-design' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh', + }, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ + id: 'prj_1', + path: projectPath, + name: 'ui', + })), + }, + } as never, + ); + + expect(handled).toBe(true); + expect(createOpencodeClientMock).toHaveBeenCalledWith({ + baseUrl: 'http://127.0.0.1:4096', + directory: projectPath, + }); + expect(listAgents).toHaveBeenCalledOnce(); + expect(promptSessionAsync).toHaveBeenCalledWith( + 'ses_1', + { text: 'Ship it', system: '当前用户叫小明', agent: 'game-design' }, + { signal: expect.any(AbortSignal) }, + ); + expect(response.statusCode).toBe(202); + expect(response.json()).toEqual({ success: true }); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('fails every shared runtime execution entry closed while the generation config latch is pending', async () => { + const promptSessionAsync = vi.fn(); + const executeSessionCommand = vi.fn(); + const summarizeSession = vi.fn(); + createOpencodeClientMock.mockReturnValue({ + promptSessionAsync, + executeSessionCommand, + summarizeSession, + }); + const restart = vi.fn(); + const manager = { + getStatus: () => ({ state: 'running' as const, port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 9, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; + await markRuntimeConfigRefreshPending(manager); + const context = { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never; + const requests = [ + ['messages', createRequest('POST', { text: 'blocked' })], + ['command', createRequest('POST', { command: 'review', arguments: '' })], + ['summarize', createRequest('POST')], + ] as const; + + for (const [endpoint, request] of requests) { + const response = createResponse(); + await handleOpencodeRoutes( + request, + response.res, + new URL(`http://127.0.0.1/api/opencode/sessions/ses_1/${endpoint}`), + context, + ); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + terminal: true, + retryable: false, + runtimeGeneration: 9, + }); + } + expect(restart).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(executeSessionCommand).not.toHaveBeenCalled(); + expect(summarizeSession).not.toHaveBeenCalled(); + expect(createOpencodeClientMock).not.toHaveBeenCalled(); + }); + + it('arms a stopped explicit apply to clear sticky readiness only after the next fresh start', async () => { + let generation = 1; + let runtimeStatus: { state: 'stopped' } | { + state: 'running'; + port: number; + url: string; + pid: number; + } = { state: 'stopped' }; + const start = vi.fn(async () => { + generation = 2; + runtimeStatus = { + state: 'running', + port: 4096, + url: 'http://127.0.0.1:4096', + pid: 4242, + }; + return runtimeStatus; + }); + const restart = vi.fn(); + const manager = { + getStatus: () => runtimeStatus, + getRuntimeGeneration: () => generation, + getRuntimeGenerationProvenance: () => 'fresh' as const, + start, + restart, + }; + await withRuntimeConfigCoordinator(manager, async (lease) => { + lease.markRefreshPending(); + lease.retainRefreshPending(); + }); + const existingAccount = { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + baseUrl: 'https://one-api.example.com/v1', + apiProtocol: 'openai-completions', + model: 'gpt-4.1-mini', + fallbackModels: [], + enabled: true, + isDefault: true, + metadata: { + customModels: ['gpt-4.1-mini'], + worksSquareCredentialMode: 'api_key', + }, + createdAt: '2026-08-17T00:00:00.000Z', + updatedAt: '2026-08-17T00:00:00.000Z', + }; + providerServiceMock.getAccount.mockResolvedValue(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValue('direct-key-k1'); + providerServiceMock.updateAccount.mockResolvedValue(existingAccount); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 })); + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true, writable: true }); + Object.defineProperty(global, 'fetch', { value: fetchMock, configurable: true, writable: true }); + try { + const applyResponse = createResponse(); + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }), + applyResponse.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + expect(applyResponse.statusCode).toBe(200); + expect(applyResponse.json()).toMatchObject({ runtimeRefreshRequired: false }); + expect(restart).not.toHaveBeenCalled(); + + const startResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST'), + startResponse.res, + new URL('http://127.0.0.1/api/opencode/start'), + { opencodeManager: manager } as never, + ); + expect(startResponse.statusCode).toBe(200); + + buildConfigSummaryMock.mockResolvedValue({ + model: 'openai/gpt-4o-mini', + smallModel: null, + providerIds: [], + enabledProviderIds: [], + providerCount: 0, + providers: [], + }); + const promptSessionAsync = vi.fn(async () => undefined); + createOpencodeClientMock.mockReturnValue({ promptSessionAsync }); + const messageResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Fresh start may execute' }), + messageResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never, + ); + expect(messageResponse.statusCode).toBe(202); + expect(promptSessionAsync).toHaveBeenCalledOnce(); + } finally { + Object.defineProperty(globalThis, 'fetch', { value: originalFetch, configurable: true, writable: true }); + Object.defineProperty(global, 'fetch', { value: originalFetch, configurable: true, writable: true }); + } + }); + + it.each([ + { + label: 'message', + path: '/api/opencode/sessions/ses_1/messages', + body: { text: 'Late completion' }, + method: 'promptSessionAsync', + }, + { + label: 'command', + path: '/api/opencode/sessions/ses_1/command', + body: { command: 'review', arguments: '' }, + method: 'executeSessionCommand', + }, + { + label: 'summarize', + path: '/api/opencode/sessions/ses_1/summarize', + body: {}, + method: 'summarizeSession', + }, + ])('suppresses a late $label completion after the acceptance timeout response', async ({ + path, + body, + method, + }) => { + vi.useFakeTimers(); + let releaseRuntime!: () => void; + const runtimeGate = new Promise((resolve) => { + releaseRuntime = resolve; + }); + const runtimeMethod = vi.fn(async () => await runtimeGate); + createOpencodeClientMock.mockReturnValue({ [method]: runtimeMethod }); + buildConfigSummaryMock.mockResolvedValue({ + model: 'openai/gpt-4o-mini', + smallModel: null, + providerIds: [], + enabledProviderIds: [], + providerCount: 0, + providers: [], + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + const response = createResponse(); + try { + const request = handleOpencodeRoutes( + createRequest('POST', body), + response.res, + new URL(`http://127.0.0.1${path}`), + { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never, + ); + await vi.waitFor(() => expect(runtimeMethod).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + + releaseRuntime(); + await flushMicrotasks(); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + } finally { + releaseRuntime?.(); + vi.useRealTimers(); + } + }); + + it('propagates getConfig AbortError without entering the stale runtime branch', async () => { + vi.useFakeTimers(); + const abortError = new DOMException('runtime config aborted', 'AbortError'); + const getConfig = vi.fn(async (options?: { signal?: AbortSignal }) => await new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(abortError), { once: true }); + })); + const promptSessionAsync = vi.fn(async () => undefined); + createOpencodeClientMock.mockReturnValue({ getConfig, promptSessionAsync }); + providerServiceMock.getAccount.mockResolvedValue(null); + buildConfigSummaryMock.mockResolvedValue({ + model: 'niancode-user-models/deepseek-chat', + smallModel: null, + providerIds: ['niancode-user-models'], + enabledProviderIds: ['niancode-user-models'], + providerCount: 1, + providers: [{ + id: 'niancode-user-models', + baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1', + modelIds: ['deepseek-chat'], + hasApiKey: true, + headerNames: [], + }], + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + const response = createResponse(); + try { + const request = handleOpencodeRoutes( + createRequest('POST', { text: 'Abort config inspection' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never, + ); + await vi.waitFor(() => expect(getConfig).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + expect(response.json()).toMatchObject({ error: String(abortError) }); + expect(response.res.end).toHaveBeenCalledOnce(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('stops after a non-cooperative getConfig resolves past timeout', async () => { + vi.useFakeTimers(); + let releaseConfig!: () => void; + const getConfig = vi.fn(async () => await new Promise((resolve) => { + releaseConfig = () => resolve({ + provider: { + 'niancode-user-models': { + options: { baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1' }, + }, + }, + }); + })); + const promptSessionAsync = vi.fn(async () => undefined); + createOpencodeClientMock.mockReturnValue({ getConfig, promptSessionAsync }); + providerServiceMock.getAccount.mockResolvedValue(null); + buildConfigSummaryMock.mockResolvedValue({ + model: 'niancode-user-models/deepseek-chat', + smallModel: null, + providerIds: ['niancode-user-models'], + enabledProviderIds: ['niancode-user-models'], + providerCount: 1, + providers: [{ + id: 'niancode-user-models', + baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1', + modelIds: ['deepseek-chat'], + hasApiKey: true, + headerNames: [], + }], + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + const response = createResponse(); + try { + const request = handleOpencodeRoutes( + createRequest('POST', { text: 'Late config result' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never, + ); + await vi.waitFor(() => expect(getConfig).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + + releaseConfig(); + await flushMicrotasks(); + expect(createOpencodeClientMock).toHaveBeenCalledOnce(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + } finally { + releaseConfig?.(); + vi.useRealTimers(); + } + }); + + it('does not create a runtime client after an early summary load resolves past timeout', async () => { + vi.useFakeTimers(); + let releaseSummary!: () => void; + buildConfigSummaryMock.mockImplementationOnce(async () => await new Promise((resolve) => { + releaseSummary = () => resolve({ + model: 'openai/gpt-4o-mini', + smallModel: null, + providerIds: [], + enabledProviderIds: [], + providerCount: 0, + providers: [], + }); + })); + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + const response = createResponse(); + try { + const request = handleOpencodeRoutes( + createRequest('POST', { text: 'Late summary' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never, + ); + await vi.waitFor(() => expect(buildConfigSummaryMock).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + expect(response.res.end).toHaveBeenCalledOnce(); + + releaseSummary(); + await flushMicrotasks(); + expect(createOpencodeClientMock).not.toHaveBeenCalled(); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + } finally { + releaseSummary?.(); + vi.useRealTimers(); + } + }); + + it('returns a typed pending response without sending when the configured Agent is absent from the live registry', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-registry-pending-')); + try { + await createConfiguredAgentProject(projectPath); + const promptSessionAsync = vi.fn(async () => undefined); + const listAgents = vi.fn(async () => [{ name: 'other-agent' }]); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + let runtimeGeneration = 1; + const context = { opencodeManager: { getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => runtimeGeneration, + getRuntimeGenerationProvenance: () => 'fresh', }, opencodeProjectStore: { getActiveProject: vi.fn(async () => ({ id: 'prj_1', - path: 'D:/repo/packages/ui', - name: 'ui', + path: projectPath, + name: 'registry-pending', })), }, - } as never, - ); + } as never; - expect(handled).toBe(true); - expect(createOpencodeClientMock).toHaveBeenCalledWith({ - baseUrl: 'http://127.0.0.1:4096', - directory: 'D:/repo/packages/ui', - }); - expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { text: 'Ship it', system: '当前用户叫小明', agent: 'game-design' }); - expect(response.statusCode).toBe(202); - expect(response.json()).toEqual({ success: true }); + const response = createResponse(); + const handled = await handleOpencodeRoutes( + createRequest('POST', { text: 'Ship it', agent: 'game-design' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + + expect(handled).toBe(true); + expect(listAgents).toHaveBeenCalledOnce(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + success: false, + code: 'OPENCODE_AGENT_REGISTRY_PENDING', + promptSent: false, + runtimeGeneration: 1, + terminal: true, + retryable: false, + }); + + listAgents.mockResolvedValue([{ id: 'game-design' }]); + const sameGenerationResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Try again', agent: 'game-design' }), + sameGenerationResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_2/messages'), + context, + ); + expect(sameGenerationResponse.statusCode).toBe(409); + expect(promptSessionAsync).not.toHaveBeenCalled(); + + runtimeGeneration = 2; + const rolloverResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'After restart', agent: 'game-design' }), + rolloverResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_3/messages'), + context, + ); + expect(rolloverResponse.statusCode).toBe(202); + expect(promptSessionAsync).toHaveBeenCalledOnce(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('keeps a same-id live Agent pending when the runtime generation was attached', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-attached-route-')); + try { + await createConfiguredAgentProject(projectPath); + const promptSessionAsync = vi.fn(async () => undefined); + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + + const response = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Do not send', agent: 'game-design' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'attached', + }, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: projectPath, name: 'attached' })), + }, + } as never, + ); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_AGENT_REGISTRY_PENDING', + promptSent: false, + runtimeGeneration: 1, + }); + expect(listAgents).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('keeps a same-id Agent content change pending until runtime generation rollover', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-generation-rollover-')); + try { + const originalAgent = createConfiguredAgent(); + const originalConfig = await createConfiguredAgentProject(projectPath, originalAgent); + const promptSessionAsync = vi.fn(async () => undefined); + const listAgents = vi.fn(async () => [{ name: originalAgent.id }]); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + let runtimeGeneration = 1; + const context = { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => runtimeGeneration, + getRuntimeGenerationProvenance: () => 'fresh', + }, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ + id: 'prj_1', + path: projectPath, + name: 'generation-rollover', + })), + }, + } as never; + + const initialResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'First prompt', agent: originalAgent.id }), + initialResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + expect(initialResponse.statusCode).toBe(202); + expect(promptSessionAsync).toHaveBeenCalledOnce(); + + await writeProjectConfig(projectPath, { + ...originalConfig, + initialized: true, + agents: [createConfiguredAgent({ + prompt: 'Use the revised same-id instructions.', + updatedAt: '2026-07-11T01:00:00.000Z', + })], + }); + + const pendingResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Second prompt', agent: originalAgent.id }), + pendingResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_2/messages'), + context, + ); + expect(pendingResponse.statusCode).toBe(409); + expect(pendingResponse.json()).toMatchObject({ + success: false, + code: 'OPENCODE_AGENT_REGISTRY_PENDING', + promptSent: false, + runtimeGeneration: 1, + }); + expect(promptSessionAsync).toHaveBeenCalledOnce(); + + runtimeGeneration = 2; + const rolloverResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Third prompt', agent: originalAgent.id }), + rolloverResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_3/messages'), + context, + ); + expect(rolloverResponse.statusCode).toBe(202); + expect(listAgents).toHaveBeenCalled(); + expect(promptSessionAsync).toHaveBeenCalledTimes(2); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('captures the old Agent baseline when the first config update occurs during runtime startup', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-first-observation-update-')); + try { + const originalConfig = await createConfiguredAgentProject(projectPath); + const project = { id: 'prj_1', path: projectPath, name: 'first-observation-update' }; + let runtimeState: 'starting' | 'running' = 'starting'; + const manager = { + getStatus: () => ({ + state: runtimeState, + port: 4096, + ...(runtimeState === 'running' ? { url: 'http://127.0.0.1:4096' } : {}), + }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => runtimeState === 'starting' ? 'starting' : 'fresh', + }; + const context = { + opencodeManager: manager, + opencodeProjectStore: { + listProjects: vi.fn(async () => [project]), + getActiveProject: vi.fn(async () => project), + }, + } as never; + const revisedConfig = { + ...originalConfig, + agents: [createConfiguredAgent({ + prompt: 'This same-id edit was not loaded by the running runtime.', + updatedAt: '2026-07-11T02:00:00.000Z', + })], + }; + + const updateResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('PUT', { projectId: project.id, config: revisedConfig }), + updateResponse.res, + new URL('http://127.0.0.1/api/opencode/projects/config'), + context, + ); + expect(updateResponse.statusCode).toBe(200); + + runtimeState = 'running'; + const promptSessionAsync = vi.fn(async () => undefined); + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + const promptResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Do not send yet', agent: 'game-design' }), + promptResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + + expect(promptResponse.statusCode).toBe(409); + expect(promptResponse.json()).toMatchObject({ + code: 'OPENCODE_AGENT_REGISTRY_PENDING', + promptSent: false, + runtimeGeneration: 1, + }); + expect(listAgents).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('preserves a user-modified retired managed Agent during config PUT while deleting an unchanged retired Agent', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-config-put-retired-')); + try { + const initial = await createInitialProjectConfig(projectPath); + const modifiedAgent = createConfiguredAgent({ + id: 'agent-user-modified', + roleName: '用户修改保护', + name: '小蓝', + }); + const unchangedAgent = createConfiguredAgent({ + id: 'agent-unchanged', + roleName: '未修改清理', + name: '小红', + }); + const configured = await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [modifiedAgent, unchangedAgent], + }); + const modifiedPath = join(projectPath, '.opencode', 'agent', 'agent-user-modified.md'); + const unchangedPath = join(projectPath, '.opencode', 'agent', 'agent-unchanged.md'); + await writeFile(modifiedPath, '# User-owned replacement\n', 'utf8'); + const project = { id: 'prj_1', path: projectPath, name: 'retired-agents' }; + const response = createResponse(); + + await handleOpencodeRoutes( + createRequest('PUT', { + projectId: project.id, + config: { ...configured, agents: [] }, + }), + response.res, + new URL('http://127.0.0.1/api/opencode/projects/config'), + { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh', + }, + opencodeProjectStore: { + listProjects: vi.fn(async () => [project]), + getActiveProject: vi.fn(async () => project), + }, + } as never, + ); + + expect(response.statusCode).toBe(200); + expect(await readFile(modifiedPath, 'utf8')).toBe('# User-owned replacement\n'); + await expect(stat(unchangedPath)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('serializes Agent config PUT with readiness verification and runtime prompt acceptance', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-atomic-acceptance-')); + let releasePrompt: (() => void) | undefined; + try { + const originalConfig = await createConfiguredAgentProject(projectPath); + const project = { id: 'prj_1', path: projectPath, name: 'atomic-agent' }; + let resolveRegistry!: (agents: Array<{ id: string }>) => void; + const registryGate = new Promise>((resolve) => { + resolveRegistry = resolve; + }); + let resolvePrompt!: () => void; + const promptGate = new Promise((resolve) => { + resolvePrompt = resolve; + releasePrompt = resolve; + }); + const listAgents = vi.fn(async () => await registryGate); + const promptSessionAsync = vi.fn(async () => await promptGate); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + const context = { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh', + }, + opencodeProjectStore: { + listProjects: vi.fn(async () => [project]), + getActiveProject: vi.fn(async () => project), + }, + } as never; + const promptResponse = createResponse(); + const promptRequest = handleOpencodeRoutes( + createRequest('POST', { text: 'Use the original Agent', agent: 'game-design' }), + promptResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + await vi.waitFor(() => expect(listAgents).toHaveBeenCalledOnce()); + + const updateResponse = createResponse(); + const updateRequest = handleOpencodeRoutes( + createRequest('PUT', { + projectId: project.id, + config: { + ...originalConfig, + agents: [{ ...originalConfig.agents[0], prompt: 'Changed after preflight began.' }], + }, + }), + updateResponse.res, + new URL('http://127.0.0.1/api/opencode/projects/config'), + context, + ); + + resolveRegistry([{ id: 'game-design' }]); + await vi.waitFor(() => expect(promptSessionAsync).toHaveBeenCalledOnce()); + await flushMicrotasks(); + expect(updateResponse.statusCode).toBe(0); + + resolvePrompt(); + await expect(promptRequest).resolves.toBe(true); + await expect(updateRequest).resolves.toBe(true); + expect(promptResponse.statusCode).toBe(202); + expect(updateResponse.statusCode).toBe(200); + } finally { + releasePrompt?.(); + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('aborts hung Agent acceptance and releases both runtime and project mutation coordinators', async () => { + vi.useFakeTimers(); + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-acceptance-timeout-')); + try { + const originalConfig = await createConfiguredAgentProject(projectPath); + const project = { id: 'prj_1', path: projectPath, name: 'timeout-agent' }; + let acceptanceSignal: AbortSignal | undefined; + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + const promptSessionAsync = vi.fn(async ( + _sessionID: string, + _payload: unknown, + options?: { signal?: AbortSignal }, + ) => { + acceptanceSignal = options?.signal; + return await new Promise(() => undefined); + }); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + const context = { + opencodeManager: manager, + opencodeProjectStore: { + listProjects: vi.fn(async () => [project]), + getActiveProject: vi.fn(async () => project), + }, + } as never; + const promptResponse = createResponse(); + const promptRequest = handleOpencodeRoutes( + createRequest('POST', { text: 'Hang until aborted', agent: 'game-design' }), + promptResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + await vi.waitFor(() => expect(promptSessionAsync).toHaveBeenCalledOnce()); + + const updateResponse = createResponse(); + const updateRequest = handleOpencodeRoutes( + createRequest('PUT', { + projectId: project.id, + config: { + ...originalConfig, + agents: [{ ...originalConfig.agents[0], prompt: 'Mutation after timeout.' }], + }, + }), + updateResponse.res, + new URL('http://127.0.0.1/api/opencode/projects/config'), + context, + ); + let runtimeMutationCompleted = false; + const runtimeMutation = withRuntimeConfigCoordinator(manager, async () => { + runtimeMutationCompleted = true; + }); + await flushMicrotasks(); + expect(updateResponse.statusCode).toBe(0); + expect(runtimeMutationCompleted).toBe(false); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(promptRequest).resolves.toBe(true); + await expect(updateRequest).resolves.toBe(true); + await expect(runtimeMutation).resolves.toBeUndefined(); + expect(acceptanceSignal?.aborted).toBe(true); + expect(promptResponse.statusCode).toBe(500); + expect(updateResponse.statusCode).toBe(200); + expect(runtimeMutationCompleted).toBe(true); + } finally { + vi.useRealTimers(); + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('releases Agent config mutation when listAgents ignores the acceptance AbortSignal', async () => { + vi.useFakeTimers(); + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-registry-timeout-')); + try { + const originalConfig = await createConfiguredAgentProject(projectPath); + const project = { id: 'prj_1', path: projectPath, name: 'registry-timeout' }; + const listAgents = vi.fn(async () => await new Promise>(() => undefined)); + const promptSessionAsync = vi.fn(async () => undefined); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + const context = { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh', + }, + opencodeProjectStore: { + listProjects: vi.fn(async () => [project]), + getActiveProject: vi.fn(async () => project), + }, + } as never; + const promptResponse = createResponse(); + const promptRequest = handleOpencodeRoutes( + createRequest('POST', { text: 'Registry hangs', agent: 'game-design' }), + promptResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + await vi.waitFor(() => expect(listAgents).toHaveBeenCalledOnce()); + + const updateResponse = createResponse(); + const updateRequest = handleOpencodeRoutes( + createRequest('PUT', { + projectId: project.id, + config: { + ...originalConfig, + agents: [{ ...originalConfig.agents[0], prompt: 'Update after registry timeout.' }], + }, + }), + updateResponse.res, + new URL('http://127.0.0.1/api/opencode/projects/config'), + context, + ); + await flushMicrotasks(); + expect(updateResponse.statusCode).toBe(0); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(promptRequest).resolves.toBe(true); + await expect(updateRequest).resolves.toBe(true); + expect(promptResponse.statusCode).toBe(500); + expect(updateResponse.statusCode).toBe(200); + expect(promptSessionAsync).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('does not enter a queued project acceptance callback after its timeout', async () => { + vi.useFakeTimers(); + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-queued-timeout-')); + let releaseLock!: () => void; + try { + const config = await createConfiguredAgentProject(projectPath); + const project = { id: 'prj_1', path: projectPath, name: 'queued-timeout' }; + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + let lockEntered!: () => void; + const entered = new Promise((resolve) => { + lockEntered = resolve; + }); + const lockGate = new Promise((resolve) => { + releaseLock = resolve; + }); + const holder = mutateProjectAgentRuntime(manager, projectPath, async () => { + lockEntered(); + await lockGate; + return { value: undefined, config }; + }); + await entered; + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + const promptSessionAsync = vi.fn(async () => undefined); + createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync }); + const readsBeforeRequest = vi.mocked(readProjectConfig).mock.calls.length; + const response = createResponse(); + const request = handleOpencodeRoutes( + createRequest('POST', { text: 'Queued request', agent: 'game-design' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => project), + }, + } as never, + ); + await vi.waitFor(() => { + expect(vi.mocked(readProjectConfig).mock.calls.length).toBeGreaterThan(readsBeforeRequest); + }); + const readsWhileQueued = vi.mocked(readProjectConfig).mock.calls.length; + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + + releaseLock(); + await holder; + await flushMicrotasks(); + expect(vi.mocked(readProjectConfig).mock.calls.length).toBe(readsWhileQueued); + expect(listAgents).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(mutateProjectAgentRuntime(manager, projectPath, async () => ({ + value: 'continued', + config, + }))).resolves.toBe('continued'); + } finally { + releaseLock?.(); + vi.useRealTimers(); + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('executes an Agent command through the same project readiness barrier', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-command-readiness-')); + try { + await createConfiguredAgentProject(projectPath); + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + const executeSessionCommand = vi.fn(async () => ({ id: 'msg_1' })); + createOpencodeClientMock.mockReturnValue({ listAgents, executeSessionCommand }); + + const response = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { + command: 'review', + arguments: '', + agent: 'game-design', + }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/command'), + { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh', + }, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: projectPath, name: 'agent-command' })), + }, + } as never, + ); + + expect(response.statusCode).toBe(202); + expect(listAgents).toHaveBeenCalledOnce(); + expect(executeSessionCommand).toHaveBeenCalledWith('ses_1', { + command: 'review', + arguments: '', + agent: 'game-design', + }, { signal: expect.any(AbortSignal) }); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } }); it('forwards uploaded image files to the active project async prompt', async () => { @@ -1716,7 +2796,7 @@ description: Browser debugging. expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { text: 'Describe this image', files, - }); + }, { signal: expect.any(AbortSignal) }); expect(loggerInfoMock).toHaveBeenCalledWith( '[opencode-route] Starting session prompt', { @@ -1771,11 +2851,11 @@ description: Browser debugging. providerID: 'niancode-user-models', modelID: 'deepseek-chat', }, - }); + }, { signal: expect.any(AbortSignal) }); expect(response.statusCode).toBe(202); }); - it('refreshes an expiring legacy direct Works Square credential before sending a prompt', async () => { + it('saves an expiring legacy direct Works Square credential but fails the prompt closed until manual restart', async () => { const response = createResponse(); const promptSessionAsync = vi.fn(async () => undefined); const restart = vi.fn(async () => ({ @@ -1880,7 +2960,7 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(response.statusCode).toBe(202); + expect(response.statusCode).toBe(409); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/auth/me/model-config', expect.objectContaining({ @@ -1898,13 +2978,14 @@ description: Browser debugging. }), 'fresh-ai-token', ); - expect(restart).toHaveBeenCalledOnce(); - expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { - text: '继续执行', - model: { - providerID: 'niancode-user-models', - modelID: 'qwen3.7-max', - }, + expect(restart).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.json()).toMatchObject({ + success: false, + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + terminal: true, + retryable: false, }); } finally { Object.defineProperty(globalThis, 'fetch', { @@ -1921,7 +3002,7 @@ description: Browser debugging. } }); - it('restarts a stale runtime before sending when user models should use the local AI proxy', async () => { + it('does not restart a shared running runtime when provider config is stale before a second Session prompt', async () => { const response = createResponse(); const promptSessionAsync = vi.fn(async () => undefined); const restart = vi.fn(async () => { @@ -1991,18 +3072,103 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(restart).toHaveBeenCalledOnce(); - expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { - text: 'Ship it', - model: { - providerID: 'niancode-user-models', - modelID: 'deepseek-chat', - }, + expect(restart).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + success: false, + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + terminal: true, + retryable: false, }); - expect(response.statusCode).toBe(202); }); - it('restarts before sending when the running user-model provider list is stale', async () => { + it('does not share an in-flight direct credential refresh across manager identities', async () => { + const existingAccount = { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + baseUrl: 'https://token.nianxx.cn/v1', + apiProtocol: 'openai-completions', + model: 'qwen3.7-max', + fallbackModels: [], + enabled: true, + isDefault: true, + metadata: { + customModels: ['qwen3.7-max'], + worksSquareCredentialMode: 'works_square_ai_gateway', + worksSquareCredentialExpiresAt: new Date(Date.now() + 30_000).toISOString(), + }, + createdAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }; + providerServiceMock.getAccount.mockResolvedValue(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValue('old-ai-token'); + providerServiceMock.updateAccount.mockResolvedValue(existingAccount); + storeWorksSquareSession({ + accessToken: 'works-access-token', + refreshToken: null, + expiresAt: Date.now() + 60 * 60 * 1000, + }); + const payload = JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://token.nianxx.cn/v1', + api_key: 'fresh-ai-token', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['qwen3.7-max'], + }); + let resolveFirst!: (response: Response) => void; + const firstFetch = new Promise((resolve) => { + resolveFirst = resolve; + }); + const fetchMock = vi.fn() + .mockImplementationOnce(async () => await firstFetch) + .mockResolvedValueOnce(new Response(payload, { status: 200 })); + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true, writable: true }); + Object.defineProperty(global, 'fetch', { value: fetchMock, configurable: true, writable: true }); + const projectStore = { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }; + const stoppedManager = { + getStatus: () => ({ state: 'stopped' }), + getRuntimeGeneration: () => 0, + getRuntimeGenerationProvenance: () => 'unknown' as const, + }; + const runningManager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + try { + const stoppedRequest = handleOpencodeRoutes( + createRequest('POST', { text: 'manager A' }), + createResponse().res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_a/messages'), + { opencodeManager: stoppedManager, opencodeProjectStore: projectStore } as never, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const runningRequest = handleOpencodeRoutes( + createRequest('POST', { text: 'manager B' }), + createResponse().res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_b/messages'), + { opencodeManager: runningManager, opencodeProjectStore: projectStore } as never, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + resolveFirst(new Response(payload, { status: 200 })); + await expect(Promise.all([stoppedRequest, runningRequest])).resolves.toEqual([true, true]); + } finally { + Object.defineProperty(globalThis, 'fetch', { value: originalFetch, configurable: true, writable: true }); + Object.defineProperty(global, 'fetch', { value: originalFetch, configurable: true, writable: true }); + clearWorksSquareSession(); + } + }); + + it('fails closed without restarting when the running user-model provider list is stale', async () => { const response = createResponse(); const stalePromptSessionAsync = vi.fn(async () => undefined); const promptSessionAsync = vi.fn(async () => undefined); @@ -2081,19 +3247,17 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(restart).toHaveBeenCalledOnce(); + expect(restart).not.toHaveBeenCalled(); expect(stalePromptSessionAsync).not.toHaveBeenCalled(); - expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { - text: 'Ship it', - model: { - providerID: 'niancode-user-models', - modelID: 'qwen-max', - }, + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, }); - expect(response.statusCode).toBe(202); }); - it('restarts before sending when a running user-model image modality is stale', async () => { + it('fails closed without restarting when a running user-model image modality is stale', async () => { const response = createResponse(); const stalePromptSessionAsync = vi.fn(async () => undefined); const promptSessionAsync = vi.fn(async () => undefined); @@ -2186,34 +3350,26 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(restart).toHaveBeenCalledOnce(); + expect(restart).not.toHaveBeenCalled(); expect(stalePromptSessionAsync).not.toHaveBeenCalled(); - expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { - text: '看看这张图', - files: [{ - type: 'file', - mime: 'image/png', - url: 'data:image/png;base64,AAECAw==', - filename: 'scene.png', - }], - model: { - providerID: 'niancode-user-models', - modelID: 'qwen3.6-plus', - }, + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, }); - expect(response.statusCode).toBe(202); }); it.each([ { - label: 'restarts when a verified Qwen model limit is missing', + label: 'fails closed when a verified Qwen model limit is missing', runningLimit: undefined, providerBaseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', runningProviderBaseUrl: undefined, expectedRestarts: 1, }, { - label: 'restarts when a verified Qwen model limit is mismatched', + label: 'fails closed when a verified Qwen model limit is mismatched', runningLimit: { context: 999_999, output: 65_536 }, providerBaseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', runningProviderBaseUrl: undefined, @@ -2227,14 +3383,14 @@ description: Browser debugging. expectedRestarts: 0, }, { - label: 'restarts a direct Works Square runtime when a verified Qwen model limit is missing', + label: 'fails closed for a direct Works Square runtime when a verified Qwen model limit is missing', runningLimit: undefined, providerBaseUrl: 'https://token.nianxx.cn/v1', runningProviderBaseUrl: undefined, expectedRestarts: 1, }, { - label: 'restarts when a verified Qwen direct provider base URL is stale', + label: 'fails closed when a verified Qwen direct provider base URL is stale', runningLimit: { context: 1_000_000, output: 65_536 }, providerBaseUrl: 'https://token.nianxx.cn/v1', runningProviderBaseUrl: 'https://old-gateway.example.com/v1', @@ -2341,13 +3497,21 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(restart).toHaveBeenCalledTimes(expectedRestarts); + expect(restart).not.toHaveBeenCalled(); expect(initialPromptSessionAsync).toHaveBeenCalledTimes(expectedRestarts === 0 ? 1 : 0); - expect(restartedPromptSessionAsync).toHaveBeenCalledTimes(expectedRestarts); + expect(restartedPromptSessionAsync).not.toHaveBeenCalled(); if (providerBaseUrl !== 'http://127.0.0.1:13210/api/ai-proxy/v1') { expect(providerServiceMock.updateAccount).not.toHaveBeenCalled(); } - expect(response.statusCode).toBe(202); + if (expectedRestarts === 0) { + expect(response.statusCode).toBe(202); + } else { + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + }); + } }); it('treats a remote lookalike path with a verified Qwen limit as a direct provider', async () => { @@ -2426,7 +3590,6 @@ description: Browser debugging. })), promptSessionAsync, })); - const handled = await handleOpencodeRoutes( createRequest('POST', { text: 'Continue safely' }), response.res, @@ -2660,9 +3823,9 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(restart).toHaveBeenCalledOnce(); + expect(restart).not.toHaveBeenCalled(); expect(initialPromptSessionAsync).not.toHaveBeenCalled(); - expect(restartedPromptSessionAsync).toHaveBeenCalledOnce(); + expect(restartedPromptSessionAsync).not.toHaveBeenCalled(); const serializedWarnings = JSON.stringify(loggerWarnMock.mock.calls); for (const secret of [ 'distinct-user', @@ -2672,7 +3835,11 @@ description: Browser debugging. ]) { expect(serializedWarnings).not.toContain(secret); } - expect(response.statusCode).toBe(202); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + }); }); it('skips the verified Qwen freshness guard without a normalized provider base URL', async () => { @@ -2759,7 +3926,14 @@ description: Browser debugging. }; providerServiceMock.getAccount.mockResolvedValue(existingAccount); providerServiceMock.getAccountApiKey.mockResolvedValue('old-host-api-token'); - providerServiceMock.updateAccount.mockResolvedValue(existingAccount); + let releaseRebind!: () => void; + const rebindGate = new Promise((resolve) => { + releaseRebind = resolve; + }); + providerServiceMock.updateAccount.mockImplementationOnce(async () => { + await rebindGate; + return existingAccount; + }); buildConfigSummaryMock.mockResolvedValue({ model: 'niancode-user-models/deepseek-chat', smallModel: null, @@ -2787,25 +3961,42 @@ description: Browser debugging. })), promptSessionAsync, })); + let runtimeGeneration = 1; + let provenance: 'fresh' | 'attached' = 'fresh'; + const context = { + opencodeManager: { + getStatus: () => runtimeStatus, + getRuntimeGeneration: () => runtimeGeneration, + getRuntimeGenerationProvenance: () => provenance, + restart, + }, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ + id: 'prj_1', + path: 'D:/repo/packages/ui', + name: 'ui', + })), + }, + } as never; - const handled = await handleOpencodeRoutes( + const firstRequest = handleOpencodeRoutes( createRequest('POST', { text: 'Ship it' }), response.res, new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), - { - opencodeManager: { - getStatus: () => runtimeStatus, - restart, - }, - opencodeProjectStore: { - getActiveProject: vi.fn(async () => ({ - id: 'prj_1', - path: 'D:/repo/packages/ui', - name: 'ui', - })), - }, - } as never, + context, ); + await vi.waitFor(() => expect(providerServiceMock.updateAccount).toHaveBeenCalledOnce()); + const sameGenerationResponse = createResponse(); + const secondRequest = handleOpencodeRoutes( + createRequest('POST', { text: 'Concurrent request stays blocked' }), + sameGenerationResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_2/messages'), + context, + ); + await flushMicrotasks(); + expect(sameGenerationResponse.statusCode).toBe(0); + releaseRebind(); + const [handled] = await Promise.all([firstRequest, secondRequest]); expect(handled).toBe(true); expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( @@ -2813,15 +4004,209 @@ description: Browser debugging. existingAccount, 'current-host-api-token', ); - expect(restart).toHaveBeenCalledOnce(); - expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', { - text: 'Ship it', - model: { - providerID: 'niancode-user-models', - modelID: 'deepseek-chat', - }, + expect(restart).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, }); - expect(response.statusCode).toBe(202); + expect(sameGenerationResponse.statusCode).toBe(409); + expect(promptSessionAsync).not.toHaveBeenCalled(); + providerServiceMock.getAccountApiKey.mockResolvedValue('current-host-api-token'); + + runtimeGeneration = 2; + provenance = 'attached'; + const attachedResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Attached is not authoritative' }), + attachedResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_3/messages'), + context, + ); + expect(attachedResponse.statusCode).toBe(409); + expect(promptSessionAsync).not.toHaveBeenCalled(); + + runtimeGeneration = 3; + provenance = 'fresh'; + const freshResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Fresh generation' }), + freshResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_4/messages'), + context, + ); + expect(freshResponse.statusCode).toBe(202); + expect(promptSessionAsync).toHaveBeenCalledOnce(); + }); + + it('fails closed when rebinding the local proxy Host API token is rejected', async () => { + const response = createResponse(); + const promptSessionAsync = vi.fn(async () => undefined); + const restart = vi.fn(); + const existingAccount = { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', + apiProtocol: 'openai-completions', + model: 'deepseek-chat', + fallbackModels: [], + enabled: true, + isDefault: true, + metadata: { worksSquareCredentialMode: 'works_square_ai_gateway_proxy' }, + createdAt: '2026-07-06T00:00:00.000Z', + updatedAt: '2026-07-06T00:00:00.000Z', + }; + providerServiceMock.getAccount.mockResolvedValue(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValue('old-host-api-token'); + providerServiceMock.updateAccount.mockRejectedValue(new Error('secure storage unavailable')); + buildConfigSummaryMock.mockResolvedValue({ + model: 'niancode-user-models/deepseek-chat', + smallModel: null, + providerIds: ['niancode-user-models'], + enabledProviderIds: ['niancode-user-models'], + providerCount: 1, + providers: [{ + id: 'niancode-user-models', + baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1', + modelIds: ['deepseek-chat'], + hasApiKey: true, + headerNames: [], + }], + }); + createOpencodeClientMock.mockReturnValue({ + getConfig: vi.fn(async () => ({ + provider: { + 'niancode-user-models': { + options: { baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1' }, + }, + }, + })), + promptSessionAsync, + }); + + await handleOpencodeRoutes( + createRequest('POST', { text: 'Do not send' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh', + restart, + }, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never, + ); + + expect(response.statusCode).toBe(409); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, + }); + expect(restart).not.toHaveBeenCalled(); + expect(promptSessionAsync).not.toHaveBeenCalled(); + }); + + it('revokes a timed-out local token rebind before its late continuation can escape the lease', async () => { + vi.useFakeTimers(); + const existingAccount = { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', + apiProtocol: 'openai-completions', + model: 'deepseek-chat', + fallbackModels: [], + enabled: true, + isDefault: true, + metadata: { worksSquareCredentialMode: 'works_square_ai_gateway_proxy' }, + createdAt: '2026-07-06T00:00:00.000Z', + updatedAt: '2026-07-06T00:00:00.000Z', + }; + providerServiceMock.getAccount.mockResolvedValue(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValue('old-host-api-token'); + let releaseRebind!: () => void; + providerServiceMock.updateAccount.mockImplementationOnce(async () => await new Promise((resolve) => { + releaseRebind = resolve; + })); + buildConfigSummaryMock.mockResolvedValue({ + model: 'niancode-user-models/deepseek-chat', + smallModel: null, + providerIds: ['niancode-user-models'], + enabledProviderIds: ['niancode-user-models'], + providerCount: 1, + providers: [{ + id: 'niancode-user-models', + baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1', + modelIds: ['deepseek-chat'], + hasApiKey: true, + headerNames: [], + }], + }); + const promptSessionAsync = vi.fn(async () => undefined); + const getConfig = vi.fn(async () => ({ + provider: { + 'niancode-user-models': { + options: { baseURL: 'http://127.0.0.1:13210/api/ai-proxy/v1' }, + }, + }, + })); + createOpencodeClientMock.mockReturnValue({ + getConfig, + promptSessionAsync, + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart: vi.fn(), + }; + const context = { + opencodeManager: manager, + opencodeProjectStore: { + getActiveProject: vi.fn(async () => ({ id: 'prj_1', path: 'D:/repo/packages/ui', name: 'ui' })), + }, + } as never; + const response = createResponse(); + try { + const request = handleOpencodeRoutes( + createRequest('POST', { text: 'Do not escape timeout' }), + response.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_1/messages'), + context, + ); + await vi.waitFor(() => expect(providerServiceMock.updateAccount).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + + const nextResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST', { text: 'Still blocked by the timed-out mutation' }), + nextResponse.res, + new URL('http://127.0.0.1/api/opencode/sessions/ses_2/messages'), + context, + ); + expect(nextResponse.statusCode).toBe(409); + expect(promptSessionAsync).not.toHaveBeenCalled(); + + releaseRebind(); + await flushMicrotasks(); + expect(getConfig).not.toHaveBeenCalled(); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + } finally { + releaseRebind?.(); + vi.useRealTimers(); + } }); it('renames a session through the active project directory', async () => { @@ -3446,7 +4831,6 @@ description: Browser debugging. createRequest('POST', { command: ' Review ', arguments: ' \tPRIVATE staged changes\r\n ', - agent: ' game-development ', model: ' niancode-user-models/qwen3.7-plus ', variant: ' high ', parts, @@ -3460,7 +4844,6 @@ description: Browser debugging. expect(executeSessionCommand).toHaveBeenCalledWith('ses_1', { command: 'Review', arguments: ' \tPRIVATE staged changes\r\n ', - agent: 'game-development', model: 'niancode-user-models/qwen3.7-plus', variant: 'high', parts: [ @@ -3476,7 +4859,7 @@ description: Browser debugging. url: 'data:IMAGE/WEBP;base64,QUFB', }, ], - }); + }, { signal: expect.any(AbortSignal) }); expect(loggerInfoMock).toHaveBeenCalledWith( '[opencode-route] Executing session command', { @@ -3518,7 +4901,7 @@ description: Browser debugging. expect(executeSessionCommand).toHaveBeenCalledWith('ses_1', { command: 'review', arguments: '', - }); + }, { signal: expect.any(AbortSignal) }); expect(response.statusCode).toBe(202); }); @@ -3553,7 +4936,7 @@ description: Browser debugging. command, arguments: argumentsText, parts, - }); + }, { signal: expect.any(AbortSignal) }); }); it('rejects missing names, oversized arguments, invalid parts, and invalid runtime context', async () => { @@ -3805,10 +5188,10 @@ description: Browser debugging. expect(methods.summarizeSession).toHaveBeenCalledWith('ses_1', { providerID: 'niancode-user-models', modelID: 'qwen3.7-plus', - }); + }, { signal: expect.any(AbortSignal) }); }); - it('refreshes an expiring direct Works Square credential before summarizing with the Main model', async () => { + it('fails summarize closed without restart when a direct Works Square credential needs refresh', async () => { const response = createResponse(); const summarizeSession = vi.fn(async () => true); const restart = vi.fn(async () => ({ @@ -3913,7 +5296,7 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(response.statusCode).toBe(202); + expect(response.statusCode).toBe(409); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/auth/me/model-config', expect.objectContaining({ @@ -3931,11 +5314,12 @@ description: Browser debugging. }), 'fresh-ai-token', ); - expect(restart).toHaveBeenCalledOnce(); - expect(buildConfigSummaryMock).toHaveBeenCalledOnce(); - expect(summarizeSession).toHaveBeenCalledWith('ses_1', { - providerID: 'niancode-user-models', - modelID: 'qwen3.7-max', + expect(restart).not.toHaveBeenCalled(); + expect(buildConfigSummaryMock).not.toHaveBeenCalled(); + expect(summarizeSession).not.toHaveBeenCalled(); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, }); } finally { Object.defineProperty(globalThis, 'fetch', { @@ -3963,7 +5347,7 @@ description: Browser debugging. runningProviderBaseUrl: 'https://token.nianxx.cn/v1', runningLimit: { context: 999_999, output: 65_536 }, }, - ])('restarts stale direct Qwen $label before command execution', async ({ + ])('fails command closed for stale direct Qwen $label without restart', async ({ runningProviderBaseUrl, runningLimit, }) => { @@ -4054,14 +5438,15 @@ description: Browser debugging. ); expect(handled).toBe(true); - expect(response.statusCode).toBe(202); + expect(response.statusCode).toBe(409); expect(buildConfigSummaryMock).toHaveBeenCalledOnce(); - expect(restart).toHaveBeenCalledOnce(); + expect(restart).not.toHaveBeenCalled(); expect(providerServiceMock.updateAccount).not.toHaveBeenCalled(); expect(initialExecuteSessionCommand).not.toHaveBeenCalled(); - expect(restartedExecuteSessionCommand).toHaveBeenCalledWith('ses_1', { - command: 'review', - arguments: '', + expect(restartedExecuteSessionCommand).not.toHaveBeenCalled(); + expect(response.json()).toMatchObject({ + code: 'OPENCODE_RUNTIME_CONFIG_PENDING', + promptSent: false, }); }); diff --git a/tests/unit/opencode-session-run-machine.test.ts b/tests/unit/opencode-session-run-machine.test.ts index 1f115eb..fdbdd01 100644 --- a/tests/unit/opencode-session-run-machine.test.ts +++ b/tests/unit/opencode-session-run-machine.test.ts @@ -33,11 +33,26 @@ describe('opencode session run machine', () => { }); expect(isSessionRunActive(posting)).toBe(true); - const running = transitionSessionRunState(posting, { + const accepted = transitionSessionRunState(posting, { type: 'post_accepted', runId: 1, }); + expect(accepted.phase).toBe('posting'); + expect(isSessionRunActive(accepted)).toBe(true); + + const earlyIdle = transitionSessionRunState(accepted, { + type: 'remote_idle', + runId: 1, + }); + + expect(earlyIdle).toEqual(accepted); + + const running = transitionSessionRunState(earlyIdle, { + type: 'remote_busy', + runId: 1, + }); + expect(running.phase).toBe('running'); expect(isSessionRunActive(running)).toBe(true); }); @@ -47,7 +62,11 @@ describe('opencode session run machine', () => { createIdleSessionRunState(), { type: 'send_started', runId: 2, promptId: 'msg_active' }, ); - const withQueue = transitionSessionRunState(running, { + const acknowledged = transitionSessionRunState(running, { + type: 'remote_busy', + runId: 2, + }); + const withQueue = transitionSessionRunState(acknowledged, { type: 'queue_prompt', prompt: { id: 'queued_1', text: 'second' }, }); @@ -173,7 +192,11 @@ describe('opencode session run machine', () => { createIdleSessionRunState(), { type: 'send_started', runId: 9, promptId: 'compact-ses_1' }, ); - const completed = transitionSessionRunState(posting, { + const running = transitionSessionRunState(posting, { + type: 'remote_busy', + runId: 9, + }); + const completed = transitionSessionRunState(running, { type: 'remote_idle', runId: 9, }); @@ -195,7 +218,7 @@ describe('opencode session run machine', () => { { type: 'remote_failed' as const, runId: 10, error: 'POST failed after early idle' }, { phase: 'idle', - queue: [{ id: 'queued_1', text: 'second' }], + queue: [], terminalReason: 'failed', error: 'POST failed after early idle', }, diff --git a/tests/unit/opencode-store.test.ts b/tests/unit/opencode-store.test.ts index cf6dd77..2a71080 100644 --- a/tests/unit/opencode-store.test.ts +++ b/tests/unit/opencode-store.test.ts @@ -1170,6 +1170,7 @@ describe('opencode store', () => { const abortResponse = createDeferred<{ success: boolean }>(); const postedTexts: string[] = []; let abortRequested = false; + let manualPromptFinished = false; createHostEventSourceMock.mockImplementation((url: string) => new MockEventSource(url)); useOpencodeStore.setState({ @@ -1183,15 +1184,26 @@ describe('opencode store', () => { return { success: true }; } if (path === '/api/opencode/sessions/status') { - return { statuses: { ses_1: abortRequested ? { type: 'idle' } : { type: 'busy' } } }; + return { + statuses: { + ses_1: abortRequested && manualPromptFinished + ? { type: 'idle' } + : { type: 'busy' }, + }, + }; } if (path === '/api/opencode/sessions/ses_1/messages') { return { - messages: postedTexts.map((text, index) => ({ - id: `msg_${index}`, - role: 'user', - content: text, - })), + messages: [ + ...postedTexts.map((text, index) => ({ + id: `msg_${index}`, + role: 'user', + content: text, + })), + ...(manualPromptFinished + ? [{ id: 'msg_manual_assistant', role: 'assistant', content: 'Done' }] + : []), + ], }; } if (path === '/api/opencode/sessions/ses_1/abort' && init?.method === 'POST') { @@ -1223,7 +1235,13 @@ describe('opencode store', () => { await aborting; await firstPrompt; - await useOpencodeStore.getState().sendSessionMessage('ses_1', 'Manual prompt after abort'); + const manualPrompt = useOpencodeStore.getState() + .sendSessionMessage('ses_1', 'Manual prompt after abort'); + await vi.waitFor(() => + expect(postedTexts).toEqual(['First prompt', 'Manual prompt after abort'])); + manualPromptFinished = true; + await vi.advanceTimersByTimeAsync(750); + await manualPrompt; expect(postedTexts).toEqual(['First prompt', 'Manual prompt after abort']); } finally { vi.useRealTimers(); @@ -1695,7 +1713,12 @@ describe('opencode store', () => { return { messages: [{ id: 'msg_1', role: 'assistant', content: 'Session one done.' }] }; } if (path === '/api/opencode/sessions/ses_2/messages') { - return { messages: [{ id: 'msg_2', role: 'assistant', content: 'Session two done.' }] }; + return { + messages: [ + { id: 'msg_2_user', role: 'user', content: 'Second prompt' }, + { id: 'msg_2', role: 'assistant', content: 'Session two done.' }, + ], + }; } throw new Error(`Unexpected path ${path}`); }); @@ -1718,9 +1741,448 @@ describe('opencode store', () => { expect(postedPrompts).toEqual(['First prompt', 'Second prompt']); expect(useOpencodeStore.getState().selectedSessionId).toBe('ses_2'); - expect(useOpencodeStore.getState().sessionMessages).toMatchObject([ - { id: 'msg_2', role: 'assistant', content: [{ type: 'text', text: 'Session two done.' }] }, + expect(useOpencodeStore.getState().sessionMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'msg_2', + role: 'assistant', + content: [{ type: 'text', text: 'Session two done.' }], + }), + ])); + } finally { + vi.useRealTimers(); + } + }); + + it('terminalizes only an unconfirmed second session after its bounded startup window', async () => { + const { hostApiFetch } = await import('@/lib/host-api'); + vi.useFakeTimers(); + type PromptOutcome = + | { status: 'resolved'; messages: unknown[] } + | { status: 'rejected'; error: Error }; + let outcome: PromptOutcome | null = null; + try { + const sourceB = new MockEventSource('/api/opencode/events?sessionId=ses_b'); + createHostEventSourceMock.mockReturnValueOnce(sourceB); + const postedPrompts: string[] = []; + const sessionAMessages = [ + { id: 'msg_a_user', role: 'user', content: 'Session A prompt' }, + ]; + const sessionAStreamingMessage = { + id: 'msg_a_assistant', + role: 'assistant', + content: 'Session A is still working', + }; + const sessionAStreamingTools = [{ id: 'tool_a', name: 'read', status: 'running' as const }]; + useOpencodeStore.setState({ + selectedSessionId: 'ses_b', + sessionStatuses: { + ses_a: { type: 'busy' }, + ses_b: { type: 'idle' }, + }, + sendingSessionIds: { ses_a: true }, + sessionMessagesBySessionId: { ses_a: sessionAMessages }, + streamingMessagesBySessionId: { ses_a: sessionAStreamingMessage }, + streamingToolsBySessionId: { ses_a: sessionAStreamingTools }, + }); + vi.mocked(hostApiFetch).mockImplementation(async (path: string, init?: RequestInit) => { + if (path === '/api/opencode/sessions/ses_b/messages' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as { text: string }; + postedPrompts.push(body.text); + return { success: true }; + } + if (path === '/api/opencode/sessions/status') { + return { statuses: { ses_a: { type: 'busy' } } }; + } + if (path === '/api/opencode/sessions/ses_b/messages') { + return { + messages: [{ id: 'msg_b_user', role: 'user', content: 'Session B prompt' }], + }; + } + if (path === '/api/opencode/sessions/ses_b/abort' && init?.method === 'POST') { + return { success: true }; + } + throw new Error(`Unexpected path ${path}`); + }); + + const pending = useOpencodeStore.getState() + .sendSessionMessage('ses_b', 'Session B prompt'); + const settled = pending.then( + (messages): PromptOutcome => ({ status: 'resolved', messages }), + (error: unknown): PromptOutcome => ({ + status: 'rejected', + error: error instanceof Error ? error : new Error(String(error)), + }), + ).then((result) => { + outcome = result; + return result; + }); + + try { + await vi.waitFor(() => expect(postedPrompts).toEqual(['Session B prompt'])); + await vi.advanceTimersByTimeAsync(10_500); + await Promise.resolve(); + + expect(outcome?.status).toBe('rejected'); + if (outcome?.status === 'rejected') { + expect(outcome.error.message).toMatch(/^SESSION_START_UNCONFIRMED:/); + } + const state = useOpencodeStore.getState(); + expect(state.sendingSessionIds).toEqual({ ses_a: true }); + expect(state.sendingSessionId).toBeNull(); + expect(state.sessionStatuses).toEqual({ + ses_a: { type: 'busy' }, + ses_b: { type: 'idle' }, + }); + expect(state.sessionRunStates.ses_b).toMatchObject({ + phase: 'idle', + terminalReason: 'failed', + error: expect.stringMatching(/^SESSION_START_UNCONFIRMED:/), + }); + expect(state.sessionMessagesBySessionId.ses_a).toEqual(sessionAMessages); + expect(state.streamingMessagesBySessionId.ses_a).toEqual(sessionAStreamingMessage); + expect(state.streamingToolsBySessionId.ses_a).toEqual(sessionAStreamingTools); + expect(state.sessionMessagesBySessionId.ses_b).toEqual([ + expect.objectContaining({ id: 'msg_b_user', role: 'user' }), + ]); + expect(state.sessionMessagesBySessionId.ses_b).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'assistant' }), + ]), + ); + expect(state.sessionMessagesBySessionId.ses_b.every((message) => ( + message.role === 'user' && !message.isError && !message.errorMessage + ))).toBe(true); + expect(postedPrompts).toEqual(['Session B prompt']); + } finally { + if (outcome === null) { + await useOpencodeStore.getState().abortSession('ses_b'); + await vi.advanceTimersByTimeAsync(750); + } + await settled; + } + } finally { + vi.useRealTimers(); + } + }); + + it('terminalizes an unconfirmed session when the startup status request never resolves', async () => { + const { hostApiFetch } = await import('@/lib/host-api'); + vi.useFakeTimers(); + try { + const sourceB = new MockEventSource('/api/opencode/events?sessionId=ses_b'); + const statusResponse = createDeferred<{ + statuses: Record; + }>(); + createHostEventSourceMock.mockReturnValueOnce(sourceB); + const sessionAStreamingMessage = { + id: 'msg_a_assistant', + role: 'assistant', + content: 'Session A is still working', + }; + const sessionAQueuedPrompt = { + id: 'queued_a', + text: 'Session A queued prompt', + message: { id: 'msg_a_queued', role: 'user' as const, content: 'Session A queued prompt' }, + }; + let promptPosts = 0; + vi.mocked(hostApiFetch).mockImplementation(async (path: string, init?: RequestInit) => { + if (path === '/api/opencode/sessions/ses_b/messages' && init?.method === 'POST') { + promptPosts += 1; + return { success: true }; + } + if (path === '/api/opencode/sessions/status') { + return await statusResponse.promise; + } + if (path === '/api/opencode/sessions/ses_b/messages') { + throw new Error('message polling must wait for status polling'); + } + throw new Error(`Unexpected path ${path}`); + }); + useOpencodeStore.setState({ + selectedSessionId: 'ses_b', + sessionStatuses: { + ses_a: { type: 'busy' }, + ses_b: { type: 'idle' }, + }, + sendingSessionIds: { ses_a: true }, + streamingMessagesBySessionId: { ses_a: sessionAStreamingMessage }, + queuedSessionPrompts: { ses_a: [sessionAQueuedPrompt] }, + sessionRunStates: { + ses_a: { + phase: 'running', + runId: 99, + promptId: 'msg_a_active', + queue: [sessionAQueuedPrompt], + terminalReason: null, + error: null, + suppressNextAbortError: false, + }, + }, + }); + + const outcome = useOpencodeStore.getState() + .sendSessionMessage('ses_b', 'Session B prompt') + .then( + () => ({ status: 'resolved' as const, error: null }), + (error: unknown) => ({ + status: 'rejected' as const, + error: error instanceof Error ? error : new Error(String(error)), + }), + ); + + await vi.waitFor(() => expect(promptPosts).toBe(1)); + await useOpencodeStore.getState() + .sendSessionMessage('ses_b', 'Queued after stuck startup'); + await vi.advanceTimersByTimeAsync(10_050); + await expect(outcome).resolves.toMatchObject({ + status: 'rejected', + error: { message: expect.stringMatching(/^SESSION_START_UNCONFIRMED:/) }, + }); + + const timedOutState = useOpencodeStore.getState(); + expect(timedOutState.sendingSessionIds).toEqual({ ses_a: true }); + expect(timedOutState.sessionStatuses).toEqual({ + ses_a: { type: 'busy' }, + ses_b: { type: 'idle' }, + }); + expect(timedOutState.sessionRunStates.ses_b).toMatchObject({ + phase: 'idle', + terminalReason: 'failed', + error: expect.stringMatching(/^SESSION_START_UNCONFIRMED:/), + queue: [], + }); + expect(timedOutState.queuedSessionPrompts.ses_b) + .toBeUndefined(); + expect(timedOutState.streamingMessagesBySessionId.ses_a) + .toEqual(sessionAStreamingMessage); + expect(timedOutState.sessionRunStates.ses_a).toMatchObject({ + phase: 'running', + runId: 99, + queue: [sessionAQueuedPrompt], + }); + expect(timedOutState.queuedSessionPrompts.ses_a).toEqual([sessionAQueuedPrompt]); + expect(sourceB.close).toHaveBeenCalledTimes(1); + expect(promptPosts).toBe(1); + + statusResponse.resolve({ statuses: { ses_b: { type: 'busy' } } }); + sourceB.emit('session.status', { + sessionID: 'ses_b', + status: { type: 'busy' }, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(useOpencodeStore.getState().sessionRunStates.ses_b) + .toEqual(timedOutState.sessionRunStates.ses_b); + expect(useOpencodeStore.getState().sendingSessionIds).toEqual({ ses_a: true }); + expect(promptPosts).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('terminalizes an unconfirmed session when startup message polling never resolves', async () => { + const { hostApiFetch } = await import('@/lib/host-api'); + vi.useFakeTimers(); + try { + createHostEventSourceMock.mockReturnValueOnce( + new MockEventSource('/api/opencode/events?sessionId=ses_messages_stuck'), + ); + const messageResponse = createDeferred<{ messages: unknown[] }>(); + let promptPosts = 0; + vi.mocked(hostApiFetch).mockImplementation(async (path: string, init?: RequestInit) => { + if ( + path === '/api/opencode/sessions/ses_messages_stuck/messages' + && init?.method === 'POST' + ) { + promptPosts += 1; + return { success: true }; + } + if (path === '/api/opencode/sessions/status') return { statuses: {} }; + if (path === '/api/opencode/sessions/ses_messages_stuck/messages') { + return await messageResponse.promise; + } + throw new Error(`Unexpected path ${path}`); + }); + + const outcome = useOpencodeStore.getState() + .sendSessionMessage('ses_messages_stuck', 'Stuck message poll') + .then( + () => ({ status: 'resolved' as const, error: null }), + (error: unknown) => ({ + status: 'rejected' as const, + error: error instanceof Error ? error : new Error(String(error)), + }), + ); + + await vi.waitFor(() => expect(promptPosts).toBe(1)); + await vi.advanceTimersByTimeAsync(10_050); + await expect(outcome).resolves.toMatchObject({ + status: 'rejected', + error: { message: expect.stringMatching(/^SESSION_START_UNCONFIRMED:/) }, + }); + const failedRun = useOpencodeStore.getState() + .sessionRunStates.ses_messages_stuck; + expect(failedRun).toMatchObject({ + phase: 'idle', + terminalReason: 'failed', + error: expect.stringMatching(/^SESSION_START_UNCONFIRMED:/), + }); + expect(promptPosts).toBe(1); + + messageResponse.resolve({ + messages: [ + { id: 'msg_late_user', role: 'user', content: 'Stuck message poll' }, + { id: 'msg_late_assistant', role: 'assistant', content: 'Too late' }, + ], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(useOpencodeStore.getState().sessionRunStates.ses_messages_stuck) + .toEqual(failedRun); + expect(useOpencodeStore.getState().sessionMessages) + .not.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'msg_late_assistant' }), + ])); + expect(promptPosts).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps a busy-acknowledged second session alive past the startup window', async () => { + const { hostApiFetch } = await import('@/lib/host-api'); + vi.useFakeTimers(); + let settled = false; + try { + const sourceB = new MockEventSource('/api/opencode/events?sessionId=ses_b'); + createHostEventSourceMock.mockReturnValueOnce(sourceB); + const postedPrompts: string[] = []; + let sessionBFinished = false; + useOpencodeStore.setState({ + selectedSessionId: 'ses_b', + sessionStatuses: { + ses_a: { type: 'busy' }, + ses_b: { type: 'idle' }, + }, + sendingSessionIds: { ses_a: true }, + }); + vi.mocked(hostApiFetch).mockImplementation(async (path: string, init?: RequestInit) => { + if (path === '/api/opencode/sessions/ses_b/messages' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as { text: string }; + postedPrompts.push(body.text); + return { success: true }; + } + if (path === '/api/opencode/sessions/status') { + return { + statuses: { + ses_a: { type: 'busy' }, + ses_b: sessionBFinished ? { type: 'idle' } : { type: 'busy' }, + }, + }; + } + if (path === '/api/opencode/sessions/ses_b/messages') { + return { + messages: sessionBFinished + ? [ + { id: 'msg_b_user', role: 'user', content: 'Session B prompt' }, + { id: 'msg_b_assistant', role: 'assistant', content: 'Session B done' }, + ] + : [{ id: 'msg_b_user', role: 'user', content: 'Session B prompt' }], + }; + } + if (path === '/api/opencode/sessions/ses_b/abort' && init?.method === 'POST') { + return { success: true }; + } + throw new Error(`Unexpected path ${path}`); + }); + + const pending = useOpencodeStore.getState() + .sendSessionMessage('ses_b', 'Session B prompt'); + const observed = pending.finally(() => { + settled = true; + }); + + try { + await vi.waitFor(() => expect(postedPrompts).toEqual(['Session B prompt'])); + await vi.advanceTimersByTimeAsync(10_500); + + expect(settled).toBe(false); + expect(useOpencodeStore.getState().sendingSessionIds).toEqual({ + ses_a: true, + ses_b: true, + }); + expect(useOpencodeStore.getState().sessionStatuses).toMatchObject({ + ses_a: { type: 'busy' }, + ses_b: { type: 'busy' }, + }); + expect(postedPrompts).toEqual(['Session B prompt']); + + sessionBFinished = true; + await vi.advanceTimersByTimeAsync(750); + await expect(observed).resolves.toEqual([ + expect.objectContaining({ id: 'msg_b_user', role: 'user' }), + expect.objectContaining({ id: 'msg_b_assistant', role: 'assistant' }), + ]); + expect(useOpencodeStore.getState().sendingSessionIds).toEqual({ ses_a: true }); + expect(useOpencodeStore.getState().sessionStatuses.ses_a).toEqual({ type: 'busy' }); + expect(postedPrompts).toEqual(['Session B prompt']); + } finally { + if (!settled) { + await useOpencodeStore.getState().abortSession('ses_b'); + await vi.advanceTimersByTimeAsync(750); + await observed; + } + } + } finally { + vi.useRealTimers(); + } + }); + + it('accepts assistant output observed shortly before the startup deadline', async () => { + const { hostApiFetch } = await import('@/lib/host-api'); + vi.useFakeTimers(); + try { + createHostEventSourceMock.mockReturnValueOnce( + new MockEventSource('/api/opencode/events?sessionId=ses_deadline'), + ); + let postedAt = 0; + vi.mocked(hostApiFetch).mockImplementation(async (path: string, init?: RequestInit) => { + if ( + path === '/api/opencode/sessions/ses_deadline/messages' + && init?.method === 'POST' + ) { + postedAt = Date.now(); + return { success: true }; + } + if (path === '/api/opencode/sessions/status') { + return { statuses: {} }; + } + if (path === '/api/opencode/sessions/ses_deadline/messages') { + return { + messages: Date.now() - postedAt >= 9_000 + ? [ + { id: 'msg_deadline_user', role: 'user', content: 'Deadline prompt' }, + { id: 'msg_deadline_assistant', role: 'assistant', content: 'Just in time' }, + ] + : [{ id: 'msg_deadline_user', role: 'user', content: 'Deadline prompt' }], + }; + } + throw new Error(`Unexpected path ${path}`); + }); + + const pending = useOpencodeStore.getState() + .sendSessionMessage('ses_deadline', 'Deadline prompt'); + await vi.waitFor(() => expect(postedAt).toBeGreaterThan(0)); + await vi.advanceTimersByTimeAsync(9_750); + + await expect(pending).resolves.toEqual([ + expect.objectContaining({ id: 'msg_deadline_user', role: 'user' }), + expect.objectContaining({ id: 'msg_deadline_assistant', role: 'assistant' }), ]); + expect(useOpencodeStore.getState().error).toBeNull(); + expect(useOpencodeStore.getState().sessionStatuses.ses_deadline).toEqual({ type: 'idle' }); } finally { vi.useRealTimers(); } @@ -1967,7 +2429,9 @@ describe('opencode store', () => { expect(rejected).toEqual(expect.objectContaining({ message: 'Invalid URL (POST /chat/completions)', })); - expect(useOpencodeStore.getState().error).toBe('Invalid URL (POST /chat/completions)'); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe('Invalid URL (POST /chat/completions)'); + expect(useOpencodeStore.getState().error).toBeNull(); expect(source.close).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); @@ -2017,10 +2481,10 @@ describe('opencode store', () => { await Promise.resolve(); expect(rejected).toBeInstanceOf(Error); - expect(useOpencodeStore.getState().error).toBe( + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error).toBe( 'user quota is not enough (request id: 2026070514232177672556811718704)', ); - expect(useOpencodeStore.getState().errorKind).toBe('quota_exhausted'); + expect(useOpencodeStore.getState().errorKind).toBeNull(); expect(source.close).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); @@ -2058,8 +2522,10 @@ describe('opencode store', () => { await vi.advanceTimersByTimeAsync(750); await Promise.resolve(); - expect(useOpencodeStore.getState().error).toBe('works_square_gateway_authorize_failed'); - expect(useOpencodeStore.getState().errorKind).toBe('authentication_invalid'); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe('works_square_gateway_authorize_failed'); + expect(useOpencodeStore.getState().error).toBeNull(); + expect(useOpencodeStore.getState().errorKind).toBeNull(); expect(source.close).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); @@ -2110,8 +2576,10 @@ describe('opencode store', () => { await Promise.resolve(); expect(rejected).toBeInstanceOf(Error); - expect(useOpencodeStore.getState().error).toBe('Token quota exhausted for rolling 5-hour window'); - expect(useOpencodeStore.getState().errorKind).toBe('quota_exhausted'); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe('Token quota exhausted for rolling 5-hour window'); + expect(useOpencodeStore.getState().error).toBeNull(); + expect(useOpencodeStore.getState().errorKind).toBeNull(); expect(source.close).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); @@ -2162,8 +2630,10 @@ describe('opencode store', () => { await Promise.resolve(); expect(rejected).toBeInstanceOf(Error); - expect(useOpencodeStore.getState().error).toBe('Token balance exhausted'); - expect(useOpencodeStore.getState().errorKind).toBe('quota_exhausted'); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe('Token balance exhausted'); + expect(useOpencodeStore.getState().error).toBeNull(); + expect(useOpencodeStore.getState().errorKind).toBeNull(); expect(source.close).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); @@ -2636,7 +3106,7 @@ describe('opencode store', () => { abortResponse.resolve({ success: false, error: 'abort POST failure' }); await expect(aborting).rejects.toThrow('abort POST failure'); - expect(useOpencodeStore.getState().error).toBe('abort POST failure'); + expect(useOpencodeStore.getState().error).toBeNull(); expect(useOpencodeStore.getState().sessionRunStates.ses_1?.suppressNextAbortError ?? false).toBe(false); expect(useOpencodeStore.getState().sessionRunStates.ses_1?.runId).not.toBeNull(); @@ -2954,7 +3424,8 @@ describe('opencode store', () => { await expect(useOpencodeStore.getState().sendSessionMessage('ses_1', 'Ship it')) .rejects.toThrow(providerError); - expect(useOpencodeStore.getState().error).toBe(providerError); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error).toBe(providerError); + expect(useOpencodeStore.getState().error).toBeNull(); expect(useOpencodeStore.getState().sessionStatuses.ses_1).toEqual({ type: 'idle' }); expect(useOpencodeStore.getState().sendingSessionId).toBeNull(); expect(useOpencodeStore.getState().sessionMessages).toContainEqual(expect.objectContaining({ @@ -3029,7 +3500,9 @@ describe('opencode store', () => { await expect(useOpencodeStore.getState().sendSessionMessage('ses_1', '这图上有什么?')) .rejects.toThrow(emptyResponseMessage); - expect(useOpencodeStore.getState().error).toBe(emptyResponseMessage); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe(emptyResponseMessage); + expect(useOpencodeStore.getState().error).toBeNull(); expect(useOpencodeStore.getState().sessionStatuses.ses_1).toEqual({ type: 'idle' }); expect(useOpencodeStore.getState().sendingSessionId).toBeNull(); expect(useOpencodeStore.getState().sessionMessages).toContainEqual(expect.objectContaining({ @@ -3821,6 +4294,10 @@ describe('opencode store', () => { createHostEventSourceMock.mockReturnValue( new MockEventSource('/api/opencode/events?sessionId=ses_1'), ); + useOpencodeStore.setState({ + error: 'OpenCode runtime connection lost', + errorKind: null, + }); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path === '/api/opencode/sessions/ses_1/messages' && init?.method === 'POST') { return { success: true }; @@ -3858,6 +4335,7 @@ describe('opencode store', () => { ]), ); expect(onHostAccepted).toHaveBeenCalledTimes(1); + expect(useOpencodeStore.getState().error).toBe('OpenCode runtime connection lost'); }); it('does not call onHostAccepted when Main rejects the message POST', async () => { @@ -3876,6 +4354,10 @@ describe('opencode store', () => { { onHostAccepted }, )).rejects.toThrow('Host rejected prompt'); expect(onHostAccepted).not.toHaveBeenCalled(); + expect(useOpencodeStore.getState().sessionRunStates.ses_1?.error) + .toBe('Host rejected prompt'); + expect(useOpencodeStore.getState().error).toBeNull(); + expect(useOpencodeStore.getState().errorKind).toBeNull(); }); it('keeps an accepted prompt successful when onHostAccepted throws', async () => { @@ -5763,7 +6245,7 @@ describe('opencode store', () => { expect(postedPrompts).toEqual(['First prompt', 'Second prompt']); }); - it('keeps a queued prompt blocked when an early-idle run later fails its POST', async () => { + it('drops queued prompts after an uncertain POST failure before a manual retry', async () => { const firstPost = createDeferred<{ success: boolean }>(); const sources: MockEventSource[] = []; const postedPrompts: string[] = []; @@ -5794,10 +6276,12 @@ describe('opencode store', () => { } if (path === '/api/opencode/sessions/ses_early_failure/messages') { return { - messages: [ - { id: 'msg_early_user', role: 'user', content: 'Second prompt' }, - { id: 'msg_early_assistant', role: 'assistant', content: 'Should stay queued' }, - ], + messages: postedPrompts.includes('Manual retry') + ? [ + { id: 'msg_manual_user', role: 'user', content: 'Manual retry' }, + { id: 'msg_manual_assistant', role: 'assistant', content: 'Manual retry done' }, + ] + : [], }; } throw new Error(`Unexpected request: ${path}`); @@ -5818,8 +6302,8 @@ describe('opencode store', () => { ); sources[0]?.emit('session.idle', { sessionID: 'ses_early_failure' }); await vi.waitFor(() => - expect(useOpencodeStore.getState().sessionRunStates.ses_early_failure?.terminalReason) - .toBe('completed')); + expect(useOpencodeStore.getState().sessionRunStates.ses_early_failure) + .toMatchObject({ phase: 'posting', terminalReason: null })); firstPost.reject(new Error('POST failed after early idle')); expect(await firstOutcome).toEqual(expect.objectContaining({ @@ -5831,13 +6315,21 @@ describe('opencode store', () => { expect(postedPrompts).toEqual(['First prompt']); expect(useOpencodeStore.getState().queuedSessionPrompts.ses_early_failure) - .toHaveLength(1); + .toBeUndefined(); expect(useOpencodeStore.getState().sessionRunStates.ses_early_failure) .toMatchObject({ phase: 'idle', terminalReason: 'failed', error: 'POST failed after early idle', }); + + await expect(useOpencodeStore.getState().sendSessionMessage( + 'ses_early_failure', + 'Manual retry', + )).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'msg_manual_assistant' }), + ])); + expect(postedPrompts).toEqual(['First prompt', 'Manual retry']); }); it('ignores a late compact event after a newer run token starts', async () => { @@ -5864,7 +6356,16 @@ describe('opencode store', () => { if (path === '/api/opencode/sessions/status') { return { statuses: { ses_1: { type: promptPosted ? 'idle' : 'busy' } } }; } - if (path.endsWith('/messages')) return { messages: [] }; + if (path.endsWith('/messages')) { + return { + messages: promptPosted + ? [ + { id: 'msg_prompt_user', role: 'user', content: 'new run' }, + { id: 'msg_prompt_assistant', role: 'assistant', content: 'Done' }, + ] + : [], + }; + } throw new Error(`Unexpected request: ${path}`); }); @@ -5873,6 +6374,10 @@ describe('opencode store', () => { expect(useOpencodeStore.getState().sendingSessionIds.ses_1).toBe(true)); await useOpencodeStore.getState().abortSession('ses_1'); const prompting = useOpencodeStore.getState().sendSessionMessage('ses_1', 'new run'); + promptSource.emit('session.status', { + sessionID: 'ses_1', + status: { type: 'busy' }, + }); compactPost.resolve({ success: true }); compactSource.emit('session.idle', { sessionID: 'ses_1' }); await compacting; diff --git a/tests/unit/project-agent-runtime.test.ts b/tests/unit/project-agent-runtime.test.ts new file mode 100644 index 0000000..9264958 --- /dev/null +++ b/tests/unit/project-agent-runtime.test.ts @@ -0,0 +1,336 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + createInitialProjectConfig, + writeProjectConfig, +} from '../../electron/opencode/project-config'; +import { + acceptProjectAgentRuntime, + markProjectAgentRuntimePending, + mutateProjectAgentRuntime, + observeProjectAgentRuntime, + preflightProjectAgentRuntime, +} from '../../electron/opencode/project-agent-runtime'; +import { + isRuntimeConfigRefreshPending, + withRuntimeAcceptanceTimeout, + withRuntimeConfigCoordinator, +} from '../../electron/opencode/runtime-config-readiness'; +import type { ProjectAgentConfig } from '../../shared/project-config'; + +function createAgent(): ProjectAgentConfig { + const now = '2026-08-17T00:00:00.000Z'; + return { + id: 'game-design', + avatarId: 'avatar-01', + roleName: '游戏设计伙伴', + name: '小明', + builtIn: false, + enabled: true, + model: 'openai/gpt-4o-mini', + skillIds: [], + responsibility: { + mission: '帮助用户完成游戏设计。', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + prompt: '', + archivedAt: null, + pinned: false, + createdAt: now, + updatedAt: now, + }; +} + +describe('project Agent runtime readiness', () => { + it('rethrows the runtime operation AbortError unchanged when the timeout aborts it', async () => { + vi.useFakeTimers(); + const abortError = new DOMException('runtime request aborted', 'AbortError'); + try { + const request = withRuntimeAcceptanceTimeout(async (signal) => await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(abortError), { once: true }); + }), 10_000); + const rejection = expect(request).rejects.toBe(abortError); + await vi.advanceTimersByTimeAsync(10_000); + await rejection; + } finally { + vi.useRealTimers(); + } + }); + + it('keeps an uncertain latch sticky until a successful explicit apply owns a fresh generation', async () => { + let generation = 1; + const manager = { + getRuntimeGeneration: () => generation, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + await withRuntimeConfigCoordinator(manager, async (lease) => { + lease.markRefreshPending(); + lease.retainRefreshPending(); + }); + + generation = 2; + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + await withRuntimeConfigCoordinator(manager, async (lease) => { + expect(lease.isRefreshPending()).toBe(true); + lease.markRefreshPending(); + lease.retainRefreshPending(); + }); + + generation = 3; + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + await withRuntimeConfigCoordinator(manager, async (lease) => { + expect(lease.isRefreshPending()).toBe(true); + lease.markRefreshPending(); + }); + + generation = 4; + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + }); + + it('keeps an aborted waiter tail queued until the active project mutation releases', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-aborted-waiter-tail-')); + let releaseActive!: () => void; + try { + const initial = await createInitialProjectConfig(projectPath); + const config = await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [createAgent()], + }); + const manager = { + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + }; + let activeEntered!: () => void; + const activeStarted = new Promise((resolve) => { + activeEntered = resolve; + }); + const activeGate = new Promise((resolve) => { + releaseActive = resolve; + }); + const active = mutateProjectAgentRuntime(manager, projectPath, async () => { + activeEntered(); + await activeGate; + return { value: undefined, config }; + }); + await activeStarted; + + const controller = new AbortController(); + const abortedWaiter = acceptProjectAgentRuntime( + manager, + projectPath, + { listAgents: vi.fn(async () => [{ id: 'game-design' }]) }, + 'game-design', + async () => undefined, + controller.signal, + ); + const abortError = new DOMException('queued acceptance aborted', 'AbortError'); + controller.abort(abortError); + await expect(abortedWaiter).rejects.toBe(abortError); + + let nextMutationEntered = false; + const nextMutation = mutateProjectAgentRuntime(manager, projectPath, async () => { + nextMutationEntered = true; + return { value: 'continued', config }; + }); + for (let index = 0; index < 10; index += 1) await Promise.resolve(); + expect(nextMutationEntered).toBe(false); + + releaseActive(); + await expect(active).resolves.toBeUndefined(); + await expect(nextMutation).resolves.toBe('continued'); + expect(nextMutationEntered).toBe(true); + } finally { + releaseActive?.(); + await rm(projectPath, { recursive: true, force: true }); + } + }); + it('applies the startup baseline only after the same generation becomes fresh', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-starting-unchanged-')); + try { + const initial = await createInitialProjectConfig(projectPath); + const config = await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [createAgent()], + }); + let provenance: 'starting' | 'fresh' = 'starting'; + const manager = { + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => provenance, + }; + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + + await expect(observeProjectAgentRuntime(manager, projectPath, config)).resolves.toMatchObject({ + runtimeGeneration: 1, + desiredFingerprint: expect.any(String), + appliedFingerprint: null, + }); + + provenance = 'fresh'; + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + { listAgents }, + 'game-design', + )).resolves.toEqual({ ready: true, runtimeGeneration: 1 }); + expect(listAgents).toHaveBeenCalledOnce(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('keeps a same-id startup edit pending after the generation becomes fresh until fresh rollover', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-starting-edited-')); + try { + const initial = await createInitialProjectConfig(projectPath); + const original = await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [createAgent()], + }); + let runtimeGeneration = 1; + let provenance: 'starting' | 'fresh' = 'starting'; + const manager = { + getRuntimeGeneration: () => runtimeGeneration, + getRuntimeGenerationProvenance: () => provenance, + }; + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + + await observeProjectAgentRuntime(manager, projectPath, original); + const revised = await writeProjectConfig(projectPath, { + ...original, + agents: [{ ...original.agents[0], prompt: 'Revised during startup.' }], + }); + await observeProjectAgentRuntime(manager, projectPath, revised); + + provenance = 'fresh'; + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + { listAgents }, + 'game-design', + )).resolves.toEqual({ ready: false, runtimeGeneration: 1 }); + expect(listAgents).not.toHaveBeenCalled(); + + runtimeGeneration = 2; + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + { listAgents }, + 'game-design', + )).resolves.toEqual({ ready: true, runtimeGeneration: 2 }); + expect(listAgents).toHaveBeenCalledOnce(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('never treats an attached runtime generation as an authoritative Agent baseline', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-attached-runtime-')); + try { + const initial = await createInitialProjectConfig(projectPath); + await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [createAgent()], + }); + let runtimeGeneration = 1; + let provenance: 'attached' | 'fresh' = 'attached'; + const manager = { + getRuntimeGeneration: () => runtimeGeneration, + getRuntimeGenerationProvenance: () => provenance, + }; + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + { listAgents }, + 'game-design', + )).resolves.toEqual({ ready: false, runtimeGeneration: 1 }); + expect(listAgents).not.toHaveBeenCalled(); + + runtimeGeneration = 2; + provenance = 'fresh'; + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + { listAgents }, + 'game-design', + )).resolves.toEqual({ ready: true, runtimeGeneration: 2 }); + expect(listAgents).toHaveBeenCalledOnce(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('keeps an unknown active-generation baseline pending until generation rollover', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-unknown-baseline-')); + try { + const initial = await createInitialProjectConfig(projectPath); + const config = await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [createAgent()], + }); + let runtimeGeneration = 1; + let provenance: 'unknown' | 'fresh' = 'unknown'; + const manager = { + getRuntimeGeneration: () => runtimeGeneration, + getRuntimeGenerationProvenance: () => provenance, + }; + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + const client = { listAgents }; + + await markProjectAgentRuntimePending(manager, projectPath, config); + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + client, + 'game-design', + )).resolves.toEqual({ ready: false, runtimeGeneration: 1 }); + expect(listAgents).not.toHaveBeenCalled(); + + runtimeGeneration = 2; + provenance = 'fresh'; + await expect(preflightProjectAgentRuntime( + manager, + projectPath, + client, + 'game-design', + )).resolves.toEqual({ ready: true, runtimeGeneration: 2 }); + expect(listAgents).toHaveBeenCalledOnce(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + + it('fails closed when a runtime generation exists but provenance is unavailable', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-missing-provenance-')); + try { + const initial = await createInitialProjectConfig(projectPath); + await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [createAgent()], + }); + const listAgents = vi.fn(async () => [{ id: 'game-design' }]); + + await expect(preflightProjectAgentRuntime( + { getRuntimeGeneration: () => 7 }, + projectPath, + { listAgents }, + 'game-design', + )).resolves.toEqual({ ready: false, runtimeGeneration: 7 }); + expect(listAgents).not.toHaveBeenCalled(); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/project-config.test.ts b/tests/unit/project-config.test.ts index 39bafc3..e8b26c6 100644 --- a/tests/unit/project-config.test.ts +++ b/tests/unit/project-config.test.ts @@ -194,6 +194,49 @@ describe('project-owned contact configuration', () => { expect(markdown).toContain('"*": deny'); }); + it('removes only previously generated Agent files when configured Agents are deleted', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-config-managed-agents-')); + const initial = await createInitialProjectConfig(projectPath); + const retainedAgent = createContact(); + const removedAgent = createContact({ id: 'agent-removed', name: '小红' }); + const userModifiedAgent = createContact({ id: 'agent-user-modified', name: '小蓝' }); + await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [retainedAgent, removedAgent, userModifiedAgent], + }); + await writeFile( + path.join(projectPath, '.opencode', 'agent', 'agent-user-modified.md'), + '# User replaced the generated file\n', + 'utf8', + ); + await writeFile( + path.join(projectPath, '.opencode', 'agent', 'custom-user-agent.md'), + '# User-owned Agent\n', + 'utf8', + ); + + await writeProjectConfig(projectPath, { + ...initial, + initialized: true, + agents: [retainedAgent], + }); + + expect((await readdir(path.join(projectPath, '.opencode', 'agent'))).sort()).toEqual([ + 'agent-test.md', + 'agent-user-modified.md', + 'custom-user-agent.md', + ]); + expect(await readFile( + path.join(projectPath, '.opencode', 'agent', 'agent-user-modified.md'), + 'utf8', + )).toBe('# User replaced the generated file\n'); + expect(await readFile( + path.join(projectPath, '.opencode', 'agent', 'custom-user-agent.md'), + 'utf8', + )).toBe('# User-owned Agent\n'); + }); + it('grants selected presentation and planning Skills to the materialized OpenCode Agent', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-config-planning-skills-')); const initial = await createInitialProjectConfig(projectPath); diff --git a/tests/unit/provider-routes.test.ts b/tests/unit/provider-routes.test.ts index 4512f3f..d5a2bfd 100644 --- a/tests/unit/provider-routes.test.ts +++ b/tests/unit/provider-routes.test.ts @@ -2,11 +2,16 @@ import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleProviderRoutes } from '@electron/api/routes/providers'; +import { handleOpencodeRoutes } from '@electron/api/routes/opencode'; import type { ProviderAccount } from '@electron/shared/providers/types'; import { clearWorksSquareAIGatewayCredential, getWorksSquareAIGatewaySnapshot, } from '@electron/services/works-square-ai-gateway'; +import { + isRuntimeConfigRefreshPending, + withRuntimeConfigCoordinator, +} from '@electron/opencode/runtime-config-readiness'; const providerServiceMock = vi.hoisted(() => ({ getAccount: vi.fn(), @@ -164,6 +169,7 @@ describe('provider host api routes', () => { fallbackModels: ['gpt-4o-mini'], }), importedModels: ['gpt-4.1-mini', 'gpt-4o-mini'], + runtimeRefreshRequired: true, }); expect(JSON.stringify(response.json())).not.toContain('ws-ai-token'); expect(fetchMock).toHaveBeenCalledWith( @@ -173,6 +179,7 @@ describe('provider host api routes', () => { headers: { Authorization: 'Bearer access-token', }, + signal: expect.any(AbortSignal), }, ); expect(providerServiceMock.createAccount).toHaveBeenCalledWith( @@ -280,6 +287,7 @@ describe('provider host api routes', () => { expect(response.json()).toMatchObject({ success: true, importedModels: ['deepseek-v4-pro', 'deepseek-chat'], + runtimeRefreshRequired: false, }); expect(providerServiceMock.createAccount).toHaveBeenCalledWith( expect.objectContaining({ @@ -467,6 +475,71 @@ describe('provider host api routes', () => { expect(restart).toHaveBeenCalledOnce(); }); + it('defers a direct API-key rotation without restarting and reports the runtime refresh requirement', async () => { + const existingAccount = createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + baseUrl: 'https://token.nianxx.cn/v1', + apiProtocol: 'openai-completions', + headers: { + Authorization: 'Bearer {env:NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY}', + 'X-Works-Square-AI-Token': '{env:NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY}', + }, + model: 'qwen3.7-max', + fallbackModels: [], + enabled: true, + isDefault: true, + metadata: { + customModels: ['qwen3.7-max'], + worksSquareCredentialMode: 'api_key', + }, + }); + providerServiceMock.getAccount.mockResolvedValueOnce(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1'); + providerServiceMock.updateAccount.mockResolvedValueOnce(existingAccount); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://token.nianxx.cn/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['qwen3.7-max'], + }), { status: 200 }))); + const response = createResponse(); + const restart = vi.fn(); + const manager = { + getStatus: () => ({ state: 'running' as const, port: 4096 }), + getRuntimeGeneration: () => 4, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; + + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { + opencodeManager: manager, + } as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + runtimeRefreshRequired: true, + }); + expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( + 'niancode-user-models', + expect.any(Object), + 'direct-key-k2', + ); + expect(restart).not.toHaveBeenCalled(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + }); + it('returns a failure when the runtime restart fails after importing a new token', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ @@ -487,17 +560,18 @@ describe('provider host api routes', () => { providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-ws-ai-token'); const restart = vi.fn().mockRejectedValue(new Error('runtime restart failed')); const response = createResponse(); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; await handleProviderRoutes( createRequest('POST', { accessToken: 'access-token' }), response.res, new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), - { - opencodeManager: { - getStatus: () => ({ state: 'running', port: 4096 }), - restart, - }, - } as never, + { opencodeManager: manager } as never, ); expect(response.statusCode).toBe(500); @@ -511,6 +585,361 @@ describe('provider host api routes', () => { 'host-api-token', ); expect(restart).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + }); + + it('retains a sticky latch when restart advances generation before rejecting', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 }))); + providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', + baseUrl: 'https://one-api.example.com/v1', + model: 'old-model', + })); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1'); + let generation = 1; + const restart = vi.fn(async () => { + generation = 2; + throw new Error('fresh restart rejected'); + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => generation, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; + const response = createResponse(); + + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + + expect(response.statusCode).toBe(500); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + generation = 3; + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + }); + + it('does not seed the local gateway before provider readiness can be established', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'gateway-token', + credential_mode: 'works_square_ai_gateway', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 }))); + providerServiceMock.getAccount.mockImplementationOnce(async () => await new Promise(() => undefined)); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart: vi.fn(), + }; + const response = createResponse(); + try { + const request = handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + await vi.waitFor(() => expect(providerServiceMock.getAccount).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + expect(getWorksSquareAIGatewaySnapshot()).toBeNull(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps the active generation pending when selecting the imported default account fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'fresh-ws-ai-token', + credential_mode: 'works_square_ai_gateway', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 }))); + providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({ + id: 'niancode-user-models', + model: 'old-model', + })); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-ws-ai-token'); + providerServiceMock.setDefaultAccount.mockRejectedValueOnce(new Error('default selection failed')); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart: vi.fn(), + }; + const response = createResponse(); + + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + + expect(response.statusCode).toBe(500); + expect(manager.restart).not.toHaveBeenCalled(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + }); + + it('does not arm a stopped sticky latch when explicit apply persistence fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 }))); + providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', + baseUrl: 'https://one-api.example.com/v1', + model: 'old-model', + })); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1'); + providerServiceMock.setDefaultAccount.mockRejectedValueOnce(new Error('default persistence failed')); + let generation = 1; + const manager = { + getStatus: () => ({ state: 'stopped' }), + getRuntimeGeneration: () => generation, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart: vi.fn(), + }; + await withRuntimeConfigCoordinator(manager, async (lease) => { + lease.markRefreshPending(); + lease.retainRefreshPending(); + }); + const response = createResponse(); + + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + + expect(response.statusCode).toBe(500); + expect(manager.restart).not.toHaveBeenCalled(); + generation = 2; + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + }); + + it('applies an existing deferred latch even when the persisted provider values are unchanged', async () => { + const payload = JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(payload, { status: 200 })) + .mockResolvedValueOnce(new Response(payload, { status: 200 }))); + let savedAccount = createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', + baseUrl: 'https://one-api.example.com/v1', + model: 'old-model', + }); + let savedApiKey = 'direct-key-k1'; + providerServiceMock.getAccount.mockImplementation(async () => savedAccount); + providerServiceMock.getAccountApiKey.mockImplementation(async () => savedApiKey); + providerServiceMock.updateAccount.mockImplementation(async ( + _id: string, + account: ProviderAccount, + apiKey: string, + ) => { + savedAccount = account; + savedApiKey = apiKey; + return account; + }); + let generation = 1; + const restart = vi.fn(async () => { + generation = 2; + return { state: 'running', port: 4097, pid: 4242 }; + }); + const manager = { + getStatus: () => ({ state: 'running', port: generation === 1 ? 4096 : 4097 }), + getRuntimeGeneration: () => generation, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; + + const deferred = createResponse(); + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }), + deferred.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + expect(deferred.statusCode).toBe(200); + expect(restart).not.toHaveBeenCalled(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + + const applied = createResponse(); + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }), + applied.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + expect(applied.statusCode).toBe(200); + expect(restart).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + }); + + it('serializes manual restart behind provider persistence', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 }))); + providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', + baseUrl: 'https://one-api.example.com/v1', + model: 'old-model', + })); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1'); + let releasePersistence!: () => void; + providerServiceMock.updateAccount.mockImplementationOnce(async () => await new Promise((resolve) => { + releasePersistence = () => resolve(createProviderAccount({ id: 'niancode-user-models' })); + })); + let generation = 1; + const restart = vi.fn(async () => { + generation = 2; + return { state: 'running', port: 4097, pid: 4242 }; + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => generation, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; + const providerResponse = createResponse(); + const persistence = handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }), + providerResponse.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + await vi.waitFor(() => expect(providerServiceMock.updateAccount).toHaveBeenCalledOnce()); + + const restartResponse = createResponse(); + const lifecycle = handleOpencodeRoutes( + createRequest('POST'), + restartResponse.res, + new URL('http://127.0.0.1/api/opencode/restart'), + { opencodeManager: manager } as never, + ); + await Promise.resolve(); + expect(restart).not.toHaveBeenCalled(); + + releasePersistence(); + await expect(persistence).resolves.toBe(true); + await expect(lifecycle).resolves.toBe(true); + expect(providerResponse.statusCode).toBe(200); + expect(restartResponse.statusCode).toBe(200); + expect(restart).toHaveBeenCalledOnce(); + }); + + it('revokes a timed-out provider persistence lease and suppresses its late continuation', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'direct-key-k2', + credential_mode: 'api_key', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini'], + }), { status: 200 }))); + providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', + baseUrl: 'https://one-api.example.com/v1', + model: 'old-model', + })); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1'); + let releasePersistence!: () => void; + providerServiceMock.updateAccount.mockImplementationOnce(async () => await new Promise((resolve) => { + releasePersistence = () => resolve(createProviderAccount({ id: 'niancode-user-models' })); + })); + let timedOutGeneration = 1; + const restart = vi.fn(async () => { + timedOutGeneration = 2; + return { state: 'running', port: 4097, pid: 4242 }; + }); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => timedOutGeneration, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart, + }; + const response = createResponse(); + try { + const request = handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + await vi.waitFor(() => expect(providerServiceMock.updateAccount).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(response.statusCode).toBe(500); + expect(response.res.end).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + + const restartResponse = createResponse(); + await handleOpencodeRoutes( + createRequest('POST'), + restartResponse.res, + new URL('http://127.0.0.1/api/opencode/restart'), + { opencodeManager: manager } as never, + ); + expect(restartResponse.statusCode).toBe(200); + expect(restart).toHaveBeenCalledOnce(); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true); + + releasePersistence(); + await Promise.resolve(); + expect(providerServiceMock.setDefaultAccount).not.toHaveBeenCalled(); + expect(response.res.end).toHaveBeenCalledOnce(); + } finally { + releasePersistence?.(); + vi.useRealTimers(); + } }); it('does not accept an attached server as the new provider runtime', async () => { @@ -581,4 +1010,45 @@ describe('provider host api routes', () => { expect(providerServiceMock.createAccount).not.toHaveBeenCalled(); expect(providerServiceMock.updateAccount).not.toHaveBeenCalled(); }); + + it('aborts a hung Works model-config fetch without acquiring the manager coordinator', async () => { + vi.useFakeTimers(); + let fetchSignal: AbortSignal | undefined; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + fetchSignal = init?.signal ?? undefined; + return new Response(new ReadableStream({ + start() { + // Headers arrive, but the response body intentionally never closes. + }, + }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchMock); + const manager = { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGeneration: () => 1, + getRuntimeGenerationProvenance: () => 'fresh' as const, + restart: vi.fn(), + }; + const response = createResponse(); + try { + const request = handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { opencodeManager: manager } as never, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + await expect(withRuntimeConfigCoordinator(manager, async () => 'unblocked')).resolves.toBe('unblocked'); + + await vi.advanceTimersByTimeAsync(10_000); + await expect(request).resolves.toBe(true); + expect(fetchSignal?.aborted).toBe(true); + expect(response.statusCode).toBe(500); + await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false); + expect(providerServiceMock.createAccount).not.toHaveBeenCalled(); + expect(providerServiceMock.updateAccount).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/tests/unit/provider-store-validation.test.ts b/tests/unit/provider-store-validation.test.ts index 46e6051..c517871 100644 --- a/tests/unit/provider-store-validation.test.ts +++ b/tests/unit/provider-store-validation.test.ts @@ -217,7 +217,51 @@ describe('useProviderStore importUserModelConfig()', () => { expect(result.importedModels).toEqual(['gpt-4.1-mini', 'gpt-4o-mini']); expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', { method: 'POST', - body: JSON.stringify({ accessToken: 'access-token' }), + body: JSON.stringify({ + accessToken: 'access-token', + runtimeRefresh: 'apply', + }), + }); + expect(mockFetchProviderSnapshot).toHaveBeenCalledOnce(); + }); + + it('defers runtime refresh when requested and exposes whether a manual restart is required', async () => { + const account = { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + model: 'gpt-4.1-mini', + enabled: true, + isDefault: true, + createdAt: '2026-06-19T00:00:00.000Z', + updatedAt: '2026-06-19T00:00:00.000Z', + } as const; + mockHostApiFetch.mockResolvedValueOnce({ + success: true, + account, + importedModels: ['gpt-4.1-mini'], + runtimeRefreshRequired: true, + }); + mockFetchProviderSnapshot.mockResolvedValueOnce({ + statuses: [], + accounts: [account], + vendors: [], + defaultAccountId: account.id, + }); + + const result = await useProviderStore.getState().importUserModelConfig( + 'access-token', + { runtimeRefresh: 'defer' }, + ); + + expect(result.runtimeRefreshRequired).toBe(true); + expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', { + method: 'POST', + body: JSON.stringify({ + accessToken: 'access-token', + runtimeRefresh: 'defer', + }), }); expect(mockFetchProviderSnapshot).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/providers-settings.test.tsx b/tests/unit/providers-settings.test.tsx index 21f7adb..2caf770 100644 --- a/tests/unit/providers-settings.test.tsx +++ b/tests/unit/providers-settings.test.tsx @@ -135,7 +135,42 @@ describe('ProvidersSettings', () => { render(); await waitFor(() => { - expect(providerState.importUserModelConfig).toHaveBeenCalledWith('access-token'); + expect(providerState.importUserModelConfig).toHaveBeenCalledWith('access-token', { + runtimeRefresh: 'defer', + }); + }); + }); + + it('applies a pending runtime refresh only after the user explicitly syncs models', async () => { + authState.accessToken = 'access-token'; + providerState.importUserModelConfig = vi.fn() + .mockResolvedValueOnce({ + account: baseProviderState.accounts[0], + importedModels: ['gpt-4.1-mini'], + runtimeRefreshRequired: true, + }) + .mockResolvedValueOnce({ + account: baseProviderState.accounts[0], + importedModels: ['gpt-4.1-mini'], + runtimeRefreshRequired: true, + }); + + render(); + + expect(await screen.findByTestId('providers-runtime-refresh-pending')).toBeVisible(); + expect(providerState.importUserModelConfig).toHaveBeenNthCalledWith(1, 'access-token', { + runtimeRefresh: 'defer', + }); + + fireEvent.click(screen.getByTestId('providers-sync-user-models-button')); + + await waitFor(() => { + expect(providerState.importUserModelConfig).toHaveBeenNthCalledWith(2, 'access-token', { + runtimeRefresh: 'apply', + }); + }); + await waitFor(() => { + expect(screen.queryByTestId('providers-runtime-refresh-pending')).not.toBeInTheDocument(); }); }); });