diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 4745fd4..b97e1c6 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -13,7 +13,7 @@ | 运营发布 | Works Square 审核与交付 | 公共 `play_url` | 客户端只消费服务端发布结果;真实合同校验 → OSS/CDN 生产链仍待整链验收 | | 可信发布门禁 | source+built+artifact contract | 服务端逐字节重算/合同校验 → 不可变 Release 固化 → 人工审核 | 服务端不运行项目 Vite;仍是不可绕过权威,未来 runtime 强门禁需可信 verifier | | 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 已知为空时不等待空历史;普通 prompt、项目命令和压缩不重发模型 override;不同 Session 没有 reply-duration 全局锁。provider/runtime 或 Agent 未应用时以 typed `409`、`promptSent:false` 在执行前终止,且不自动重启、reload 或 dispose 共享 runtime | -| AI 编程 Session 模型切换 | 页面模型选择器或 `/models` / `/model` | Renderer Session model action → Host API → Main OpenCode client → `POST /api/session/{sessionID}/model` | 只改变现有 OpenCode Session 的后续 turn 模型并从 Session metadata 恢复;不写伙伴默认模型、provider 配置或 runtime freshness,也不要求 runtime restart | +| AI 编程 Session 模型切换 | 页面模型选择器或 `/models` / `/model` | Renderer Session model action → Host API → Main OpenCode client → `POST /api/session/{sessionID}/model` | Main client 将内部 `modelID` 映射为 OpenCode 1.18.9 wire `ModelRef.id`;只改变现有 Session 的后续 turn 模型并从 Session metadata 恢复,不写伙伴默认模型、provider 配置或 runtime freshness,也不要求 runtime restart | | 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` | @@ -48,7 +48,7 @@ - AI 编程 Store 的 `sessionMessagesBySessionId` own-key 是加载状态契约:键缺失表示历史未知,存在且值为 `[]` 表示已知为空。只有后者可使用不读取历史的快速选择;普通历史会话选择继续刷新消息。 - AI 编程 Store 的运行态、错误、startup deadline 和内部 pending queue 按 Session 持有;top-level error 只表示真实全局错误。任何不确定的远程失败只清理对应 Session 的旧队列,手动重试不得复活失败前已排队的 prompt。 - OpenCode Session 持有现有会话的 active provider/model;伙伴 `model` 只在创建新 Session 时提供默认值。页面与 slash command 通过同一 native Session endpoint 切换,后续执行和压缩不得用伙伴默认值覆盖该选择。 -- Main 以 manager identity + runtime generation provenance 持有 runtime-config latch,并按 Agent id 持有 desired/applied 内容状态与本 generation 已知 id。owned `fresh` generation 中首次出现的唯一 id 可在 live `/agent` 暴露后应用,且不会阻断已有未变 Agent;同 id 编辑、删除后重建、live id 缺失以及 attached/unknown generation 均不能越过门禁。 +- Main 以 manager identity + runtime generation provenance 持有 runtime-config latch,并按 Agent id 持有 desired/applied 内容状态与本 generation 已知 id。owned `fresh` generation 中首次出现的唯一 id 可在 live `/agent` 暴露后应用,且不会阻断已有未变 Agent;同 id 编辑、删除后重建、live id 缺失以及 attached/unknown generation 均不能越过门禁。托管本地 AI proxy 的 fresh runtime config 直接使用当前进程 Host API token;把该已生效 token 写回 provider storage 不构成 runtime freshness 变化,attached/unknown generation 或 timeout/partial persistence 仍保持 sticky pending。 - 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 重复发错,但独立的后续检查仍有自己的报告生命周期。 @@ -70,4 +70,4 @@ ## Last Updated -2026-08-20 +2026-08-21 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 2b83f3a..b3981d1 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -18,7 +18,9 @@ | `src/pages/Chat/OpencodeChatPanel.tsx` | AI 编程 Agent 选择、首次 session 创建、per-Session 模型选择、prompt 提交与消息/压缩混合时间线 | 页面模型选择器与 `/models` / `/model` 共用 OpenCode Session 切换动作;伙伴模型只作为新 Session 默认值,选中 Session 只投影自己的 run error | | `src/lib/opencode-session-state.ts` | OpenCode 会话消息与上下文压缩时间线的规范化、hydration 和事件归并 | 压缩 UI identity 不变;native Part/event identity 用于回放去重,completed 状态不可降级 | | `src/stores/opencode.ts` | OpenCode runtime、session、消息缓存、per-Session 模型/提交/启动确认与压缩生命周期 | Session 列表 hydration 恢复当前模型;每个 Session 独立持有 model/run/error/queue,10 秒 run-token watchdog 只终止对应 Session 且不自动重放;`session.compacted` 只完成压缩项,真实 idle 才释放 run 队列 | -| `electron/api/routes/opencode.ts` | Main-owned OpenCode Session 模型切换、execution acceptance、provider/runtime freshness 与项目 Agent 门禁 | Session 模型切换调用 OpenCode native endpoint,不写 provider/伙伴配置;message/command/summarize 共用 bounded manager FIFO,普通执行路径不自动 restart/reload/dispose | +| `electron/opencode/client.ts` | Makelore 内部 OpenCode DTO 与固定 runtime HTTP wire contract | 内部 `{ providerID, modelID }` 在 Session 模型切换边界映射为 OpenCode 1.18.9 `ModelRef` 的 `{ providerID, id }`;runtime 升级时复核 schema | +| `electron/opencode/provider-config.ts` | Main-owned OpenCode provider/env 配置构建 | 托管本地 AI proxy 在 owned fresh runtime 启动时使用当前进程 Host API token;持久化旧 token 不得覆盖本次 runtime 已注入值 | +| `electron/api/routes/opencode.ts` | Main-owned OpenCode Session 模型切换、execution acceptance、provider/runtime freshness 与项目 Agent 门禁 | Session 模型切换调用 OpenCode native endpoint,不写 provider/伙伴配置;fresh runtime 已生效 proxy token 的持久化不标记 stale,attached/unknown 与不确定写入仍 fail closed;message/command/summarize 共用 bounded manager FIFO,普通执行路径不自动 restart/reload/dispose | | `electron/opencode/project-agent-runtime.ts` | 项目 Agent 逐 id desired/applied 内容状态、runtime generation provenance、live registry acceptance 与配置 mutation 串行化 | owned fresh generation 中首次出现的唯一 id 可在 live `/agent` 暴露后应用;同 id 编辑、删除后重建及 attached/unknown generation 仍 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` | @@ -79,4 +81,4 @@ ## Last Updated -2026-08-20 +2026-08-21 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 2590a3c..b70f734 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,15 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- OpenCode model-switch runtime correction source commit `cce7722` from feature + task `20260821-model-switch-runtime-fix-a83d6c91` is integrated on local + `main` by task `20260821-integrate-model-switch-fix-8f2d6c41`. The Main-owned + client now maps Makelore's internal `modelID` to OpenCode 1.18.9's wire field + `id`. An owned fresh runtime receives the current per-process Host API token + when its local-proxy provider config is built, so persisting that already + active token no longer creates a false manual-restart requirement; + attached/unknown generations and timeout/partial persistence remain + fail-closed. A rebuilt-client real local smoke is still pending. - Login-layout source commit `4d512b1` from feature task `20260821-remember-below-login-4a7c91d2` was merged into local `main` as `22f4bbc` by integration task `20260820-integrate-remember-password-5d7e3a1c`. @@ -122,7 +131,7 @@ Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/ap AI 学习现在是已启用的运营精选项目目录,并继续受登录和 `module_access.learning` 控制。Renderer 通过 Main-owned Host API 获取分页项目卡片和 README 详情;Markdown 支持 GFM、禁用原始 HTML。服务端发布时只校验图片 URL 为无凭据、默认端口、无 fragment 且当前 DNS 结果全部为公网地址的 HTTPS URL,保留地址而不下载、识别格式、转码或镜像;客户端仅为 README 图片节点启用直连,因此 SVG 和 Electron 支持的其他格式可直接显示,单图失败不阻断详情。封面和历史发布媒体继续走受控路径。详情页的下载按钮打开系统保存对话框;Main 将 ZIP 流式写入临时文件,只允许最多五跳同 Works origin 重定向,不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限,校验 SHA-256 和 ZIP 签名后原子保存,Renderer 只接收 `saved` 或 `cancelled`。课程生成、进度、本地课程库、OpenMAIC player、Agent、ASR、课堂 runtime、Learning IPC 和 player artifact 打包已删除且没有兼容读取路径;历史课程数据保留但不再读取。服务端和客户端源码契约已完成,不代表生产部署或真实账号安装包联调已经完成。 -AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。伙伴模型只作为新 Session 默认值,现有 Session 的 active model 由 OpenCode 持有;页面选择器和 `/models` / `/model` 走同一个 native Session 切换动作,后续 prompt、项目命令和上下文压缩不再用伙伴默认值覆盖选择。每个 Session 独立持有 model、run、loading、error 与内部队列;一个 Session 的回复或终止不会锁住、改写或重放另一个 Session。Host 接受或用户消息落库不算启动确认,只有明确 busy/retry、assistant、question、permission 或 typed terminal event 才确认/终结启动;独立的 10 秒 watchdog 到期时仅以 `SESSION_START_UNCONFIRMED` 终止该 Session。Main 按 Agent id 维护 desired/applied 内容状态与 runtime generation provenance;owned fresh generation 中首次出现的唯一 id 可在 live registry 暴露后启用且不阻断已有 Agent,同 id 编辑、删除后重建及 attached/unknown generation 仍保持 pending。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 和释放排队消息。 +AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。伙伴模型只作为新 Session 默认值,现有 Session 的 active model 由 OpenCode 持有;页面选择器和 `/models` / `/model` 走同一个 native Session 切换动作,Main 在 OpenCode client 边界把内部 `modelID` 映射为 1.18.9 `ModelRef.id`,后续 prompt、项目命令和上下文压缩不再用伙伴默认值覆盖选择。每个 Session 独立持有 model、run、loading、error 与内部队列;一个 Session 的回复或终止不会锁住、改写或重放另一个 Session。Host 接受或用户消息落库不算启动确认,只有明确 busy/retry、assistant、question、permission 或 typed terminal event 才确认/终结启动;独立的 10 秒 watchdog 到期时仅以 `SESSION_START_UNCONFIRMED` 终止该 Session。Main 按 Agent id 维护 desired/applied 内容状态与 runtime generation provenance;owned fresh generation 中首次出现的唯一 id 可在 live registry 暴露后启用且不阻断已有 Agent,同 id 编辑、删除后重建及 attached/unknown generation 仍保持 pending。fresh runtime 的本地 AI proxy provider 在构建时使用当前 Main Host API token;后续将该已生效 token 持久化不构成 provider/runtime 变更,attached/unknown generation 或不确定写入仍保持 pending。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。 @@ -197,10 +206,10 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 - 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 临界区内验证。新增唯一 id 的 live discovery 只在 owned fresh generation 可接受;同 id 不能证明同内容已经加载,删除后重建及 attached/unknown runtime generation 必须 fail closed。普通 message、command、summarize、Session 模型切换和后台同步不得自动重启、reload 或 dispose 共享 runtime。 +- 项目 Agent 与 provider/runtime freshness 必须在 Main 的 bounded acceptance 临界区内验证。新增唯一 id 的 live discovery 只在 owned fresh generation 可接受;同 id 不能证明同内容已经加载,删除后重建及 attached/unknown runtime generation 必须 fail closed。普通 message、command、summarize、Session 模型切换和后台同步不得自动重启、reload 或 dispose 共享 runtime。升级 OpenCode 时必须复核 native Session model wire schema;不得把已注入 fresh generation 的 Main 临时 proxy token 误判为上游 provider credential rotation。 - AI 绘画单图选择器仍精确匹配 quick reply `从作品列表选择图片`,用途再从当前 Brief medium 推断;本地化或协议文案变化可能破坏入口。扩展更多素材输入前应升级为结构化 action/purpose。 - Updater 源码错误提示不能代替发布正式签名产物;稳定 feed 缺 manifest 必须保持失败,Renderer 不得展示原始堆栈、URL、路径或错误码。升级链只有在旧版本完成发现、下载、重启和安装 smoke 后才可视为生产就绪。 ## Last Updated -2026-08-20 +2026-08-21 diff --git a/.project-docs/30-worklog/tasks/20260821-integrate-model-switch-fix-8f2d6c41.md b/.project-docs/30-worklog/tasks/20260821-integrate-model-switch-fix-8f2d6c41.md new file mode 100644 index 0000000..1ecba56 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260821-integrate-model-switch-fix-8f2d6c41.md @@ -0,0 +1,99 @@ +# Task: Integrate model switch runtime fix into main + +## Identity + +- Task ID: 20260821-integrate-model-switch-fix-8f2d6c41 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 605fef417fe2e7a5d23d4994fbd109cdfb38efcb +- Owner: codex +- Status: Blocked + +## Scope + +- Merge verified source commit `cce7722` into local `main` with normal + no-fast-forward history. +- Integrate the OpenCode Session model wire correction and fresh-runtime local + proxy Host API token binding without changing partner Agent hot-add behavior. +- Reconcile the source task's accepted promotion candidate into canonical + current state, architecture/data flow, evidence, and the pending real-client + smoke commitment. +- Preserve the separately owned untracked packaging task record unchanged. + +## Intent And Constraints + +- Keep page selection and `/models` / `/model` on OpenCode's native per-Session + mutation and prevent false runtime-refresh requirements for an owned fresh + runtime that already uses the current Main-owned proxy token. +- Preserve attached/unknown fail-closed behavior, sticky timeout/partial-write + uncertainty, true provider/model shape refreshes, and existing Agent runtime + acceptance rules. +- Treat the source feature task record as read-only source history. Do not edit + it; keep integration notes in this task record and canonical documents. +- Do not modify or commit + `.project-docs/30-worklog/tasks/20260819-package-learning-off-115-9c4d.md`. +- Merge only into local `main`; do not push, package, or claim a rebuilt-client + smoke that has not been run. + +## Outcome + +- With explicit user authorization, force-released the blocked legacy + integration ownership `20260820-integrate-remember-password-5d7e3a1c` and + adopted the known dirty `main` worktree without modifying its separately + owned untracked packaging task record. +- Merged source commit `cce7722` into the local `main` integration tree with + normal no-fast-forward history and no code conflicts. The source task record + remains read-only source history and was not added to the canonical tree. +- Integrated the OpenCode 1.18.9 Session model `id` wire mapping, current Host + API token injection for a fresh managed local-proxy runtime, and the + fresh/attached runtime acceptance distinction. Partner Agent hot-add and true + provider/runtime refresh semantics are unchanged. +- Promoted the accepted source candidate into current state, module ownership, + data flow, evidence, and a pending rebuilt-client local smoke commitment. +- Kept the merge local. No push, package, paid-provider request, or claim of a + real rebuilt-client smoke was made. + +## Verification + +- Integrated focused OpenCode client/provider config/provider route/OpenCode + route suite — 4 files, 171 tests passed. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm run lint:check` — passed with 0 errors and 6 existing warnings + in unrelated Renderer files. +- The first parallel full-test/build attempt exhausted worker/V8 process + resources: Vitest reported one unexpected worker exit after 176 files / 2047 + tests passed, and the concurrent Vite process terminated with a V8 fatal + check. Rerunning the commands independently resolved the execution failure. +- Sequential `corepack pnpm test` — 177 files, 2074 tests passed. +- Sequential `corepack pnpm run build:vite` — passed for + Renderer/Main/Preload/utility; only existing dynamic-import and large-chunk + advisory warnings were reported. +- `node ./node_modules/@playwright/test/cli.js test + tests/e2e/opencode-multichat-runtime.spec.ts` — 1 Electron E2E passed. An + earlier `pnpm exec playwright` attempt selected the separate `playwright` CLI + and failed during collection because the fixture used `@playwright/test`; no + test body ran in that attempt. +- `git diff --check` — passed with configured LF-to-CRLF notices only. +- Task-aware documentation drift remains blocked solely because the explicitly + adopted, pre-existing untracked + `.project-docs/30-worklog/tasks/20260819-package-learning-off-115-9c4d.md` + is another task's owned record. It was not modified, staged, or committed. + The checker also lists the expected nine merged source code/test files as + non-document changes. + +## Follow-ups + +- Build and relaunch a desktop application containing source `cce7722`, then + repeat page-selector and `/model` switching against the real local OpenCode + runtime without a manual runtime restart. This is recorded in canonical + commitments and remains outside this source-only integration. +- Resolve the project-docs drift-check handling for the explicitly adopted + pre-existing foreign task record before marking this integration complete or + releasing its ownership. Do not delete, edit, move, or commit that record as + part of this integration. + +## Promotion Candidates + +- Applied the source task's Session model wire and fresh-runtime local-proxy + token candidate; none remain pending for this integration. diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 560a121..6f94a41 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-21 | AI 编程模型切换真实 OpenCode wire 与 fresh runtime proxy token 修复 | 客户端实现及自动化验证通过;新构建真实本地 smoke 待执行 | 源任务 `20260821-model-switch-runtime-fix-a83d6c91`、源提交 `cce7722`、集成任务 `20260821-integrate-model-switch-fix-8f2d6c41` | 本地 1.1.8 / OpenCode 1.18.9 日志暴露旧请求缺少 `model.id`,且每进程 Host API token 被误判为 provider credential rotation。Main client 现将内部 `modelID` 映射为 wire `id`;owned fresh runtime 构建时注入当前 proxy token,持久化该已生效值不再要求 restart,attached/unknown 与 timeout/partial persistence 仍 fail closed。4 files / 171 focused、177 files / 2074 full unit、typecheck、lint(0 errors / 6 existing warnings)、production build 与 Electron E2E 1/1 通过。 | | 2026-08-20 | AI 编程 Session 模型切换与新增伙伴 hot-add | 客户端实现及自动化验证通过;真实 provider 多 Session smoke 仍沿用既有待验收项 | 源任务 `20260820-session-model-agent-hotfix-6e4c9a2f`、源提交 `c0163bc`、集成任务 `20260820-integrate-session-model-hotfix-7b3e91c4` | 页面选择器与 `/models` / `/model` 通过 OpenCode native Session endpoint 切换模型,普通执行不再覆盖选择或要求 runtime restart;Agent readiness 改为逐 id,新增唯一 id 在 owned fresh generation 被 live registry 发现后可用,同 id 编辑与删后重建仍 fail closed。405 focused、176 files / 2068 full unit、typecheck、lint(0 errors / 6 existing warnings)、Vite build 与 Electron E2E 1/1 通过。 | | 2026-08-20 | Learning 项目下载移除大小校验 | 客户端实现与自动化验证通过;真实账号安装包 smoke 待验收 | 源任务 `20260820-remove-download-size-check-4f8a2c1d`、源提交 `8509084`、集成任务 `20260820-integrate-download-size-6e3a91c2` | 聚焦 Learning 2 files / 17 tests、176 files / 2061 full unit、typecheck、lint(0 errors / 6 existing warnings)及 Renderer/Main/Preload/utility production build 通过。覆盖缺失或错误 `Content-Length`、`archiveBytes` 超过旧 512 MiB 上限时继续下载;SHA-256、ZIP 签名、同源重定向、临时文件清理和原子落盘仍保留。未执行真实账号、安装包或磁盘空间压力 smoke。 | | 2026-08-19 | AI Design 请求冻结与 mutation 隐式重放修复 | 客户端实现与自动化验证通过;安装包真实账号 smoke 待验收 | 源任务 `20260819-design-freeze-live-6e2c`、源提交 `87e4140`、集成任务 `20260819-integrate-design-freeze-3f8b7c` | 源分支通过 59 focused、2190 full unit、typecheck、lint(0 errors / 7 existing warnings)、Renderer/Main/Preload production build 与 package verifier;集成树另行通过 7 files / 134 focused、184 files / 2190 full、typecheck、同样的 lint 结果和 Renderer/Main/Preload/utility production build。覆盖 30 秒完整 Workspace JSON deadline、忽略 abort 时确定性 reject、shared refresh coalescing/释放/后续重试,以及只对 `GET`/`HEAD`/`OPTIONS` 保留 Electron→Node fallback;PATCH/POST transport failure 不再隐式二次发送。原生密码/SMS 登录与临时 `disable-http2` 诊断 bootstrap 同包保留;不据此宣称现场冻结已消失或 HTTP/2 是根因。 | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index f1af228..2f76454 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-21 | 验收修复后的真实本地 OpenCode Session 模型切换 | 构建并启动包含源 `cce7722` 的 Makelore 后 | 客户端/OpenCode 集成 | Pending | 重启桌面应用以加载新 Main 代码,在无需手动重启 runtime 的前提下分别从页面选择器和 `/model` 切换模型并发送后续 prompt;核对运行日志不再出现 `Missing key at ["model"]["id"]` 或因当前进程 Host API token 持久化产生的 false runtime-refresh pending。attached/unknown runtime 的真实 token mismatch 仍应 fail closed。 | | 2026-08-19 | 验收 AI Design 有限请求生命周期并决定正式 HTTP/2 策略 | 发布包含源 `87e4140` 的正式安装包前 | 客户端/Works 服务端/发布集成 | Pending | 使用真实账号连续执行 Quote PATCH、重试报价与继续调整,制造或等待慢响应,确认 30 秒内稳定返回、其他请求不被共同挂住、服务端无隐式重复 mutation;随后恢复 HTTP/2 重新打包做同场景对照。只有证据确认后才能移除临时 `disable-http2` 或把协议策略固化为正式决定。 | | 2026-08-17 | 验收 AI 编程真实双 Session runtime/provider 行为与同 id Agent 内容刷新边界 | 升级 bundled OpenCode、改变 provider 并发策略,或对外宣称双会话真实并发/即时应用同 id Agent 编辑前 | 客户端/OpenCode/provider 集成 | Pending | 使用明确配置的测试 provider 运行 opt-in bundled OpenCode 双 Session smoke,区分应用隔离与上游并发/串行/限流。owned fresh generation 已允许 live registry 新发现的唯一 id hot-add;若要求运行期即时应用同 id 编辑或删除后重建,仍须先提供 directory-scoped authoritative invalidation 或 whole-instance quiescence oracle,不从 `/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 置灰代替 | diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index 7df79de..06464f8 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -511,6 +511,7 @@ function expectedUserModelProxyConfig( async function rebindLocalProxyHostApiTokenIfNeeded( expectedBaseUrl: string, markPending: () => void, + runtimeAlreadyUsesCurrentHostApiToken: boolean, signal?: AbortSignal, ): Promise { const providerService = getProviderService(); @@ -532,14 +533,31 @@ async function rebindLocalProxyHostApiTokenIfNeeded( return false; } - markPending(); - await providerService.updateAccount( - NIANCODE_USER_MODEL_ACCOUNT_ID, - account, - currentHostApiToken, - ); - signal?.throwIfAborted(); - return true; + if (!runtimeAlreadyUsesCurrentHostApiToken) { + markPending(); + } + const markPendingOnAbort = runtimeAlreadyUsesCurrentHostApiToken + ? () => markPending() + : undefined; + if (markPendingOnAbort) { + signal?.addEventListener('abort', markPendingOnAbort, { once: true }); + } + try { + await providerService.updateAccount( + NIANCODE_USER_MODEL_ACCOUNT_ID, + account, + currentHostApiToken, + ); + signal?.throwIfAborted(); + } catch (error) { + markPending(); + throw error; + } finally { + if (markPendingOnAbort) { + signal?.removeEventListener('abort', markPendingOnAbort); + } + } + return !runtimeAlreadyUsesCurrentHostApiToken; } async function runtimeUsesExpectedUserModelProxy( @@ -607,7 +625,12 @@ async function ensureRuntimeUserModelProxyConfig( try { if ( expectedConfig.usesLocalProxy - && await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl, markPending, signal) + && await rebindLocalProxyHostApiTokenIfNeeded( + expectedConfig.baseUrl, + markPending, + ctx.opencodeManager.getRuntimeGenerationProvenance() === 'fresh', + signal, + ) ) { if (!allowRestart) { markPending(); diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 52a89b8..5194559 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -361,10 +361,13 @@ export async function importCurrentUserModelConfig( 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 runtimeAlreadyUsesLocalProxyApiKey = useLocalAiProxy + && ctx.opencodeManager.getRuntimeGenerationProvenance?.() === 'fresh'; + const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account) + || (!runtimeAlreadyUsesLocalProxyApiKey + && await importedProviderApiKeyChanged(providerService, existing, accountApiKey)); + signal.throwIfAborted(); const refreshAlreadyPending = lease.isRefreshPending(); const runtimeRefreshRequired = runtimeIsActive && (shouldRestartRuntime || refreshAlreadyPending); diff --git a/electron/main/index.ts b/electron/main/index.ts index 63ead60..d1584c8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -108,6 +108,7 @@ const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSession async function buildMakeloreOpencodeRuntimeConfig() { return await buildOpencodeRuntimeConfigFromNianCodeProviders({ + localProxyApiKey: getHostApiToken() || undefined, mcpServers: { [PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(), }, diff --git a/electron/opencode/client.ts b/electron/opencode/client.ts index 3584f7e..78b7b7b 100644 --- a/electron/opencode/client.ts +++ b/electron/opencode/client.ts @@ -278,7 +278,12 @@ export function createOpencodeClient(options: OpencodeClientOptions) { ): Promise => request(`/api/session/${encodeURIComponent(sessionID)}/model`, { method: 'POST', - body: JSON.stringify({ model }), + body: JSON.stringify({ + model: { + providerID: model.providerID, + id: model.modelID, + }, + }), signal: options?.signal, }), executeSessionCommand: ( diff --git a/electron/opencode/provider-config.ts b/electron/opencode/provider-config.ts index d55fe53..c23f79b 100644 --- a/electron/opencode/provider-config.ts +++ b/electron/opencode/provider-config.ts @@ -23,6 +23,7 @@ const OPENAI_COMPATIBLE_PACKAGE = '@ai-sdk/openai-compatible'; const OPENAI_PACKAGE = '@ai-sdk/openai'; const ANTHROPIC_PACKAGE = '@ai-sdk/anthropic'; const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; +const WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE = 'works_square_ai_gateway_proxy'; const OPENCODE_BUILTIN_PROVIDER_IDS = new Set([ 'anthropic', @@ -120,6 +121,7 @@ export interface BuildOpencodeRuntimeConfigOptions { accounts: ProviderAccount[]; defaultAccountId?: string | null; resolveApiKey: ResolveOpencodeProviderApiKey; + localProxyApiKey?: string; mcpServers?: Record; } @@ -303,7 +305,11 @@ export async function buildOpencodeRuntimeConfig( continue; } - const apiKey = await options.resolveApiKey(account, opencodeProviderId); + const localProxyApiKey = account.id === NIANCODE_USER_MODEL_ACCOUNT_ID + && account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE + ? options.localProxyApiKey + : undefined; + const apiKey = localProxyApiKey ?? await options.resolveApiKey(account, opencodeProviderId); if (!apiKey && accountNeedsApiKey(account)) { continue; } @@ -367,6 +373,7 @@ async function resolveNianCodeApiKey( } export interface BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions { + localProxyApiKey?: string; mcpServers?: Record; } @@ -383,6 +390,7 @@ export async function buildOpencodeRuntimeConfigFromNianCodeProviders( accounts, defaultAccountId, resolveApiKey: resolveNianCodeApiKey, + localProxyApiKey: options.localProxyApiKey, mcpServers: options.mcpServers, }); } diff --git a/tests/unit/opencode-client.test.ts b/tests/unit/opencode-client.test.ts index 995f3c6..ad5deeb 100644 --- a/tests/unit/opencode-client.test.ts +++ b/tests/unit/opencode-client.test.ts @@ -190,7 +190,7 @@ describe('opencode client', () => { body: JSON.stringify({ model: { providerID: 'niancode-user-models', - modelID: 'qwen3.7-plus', + id: 'qwen3.7-plus', }, }), signal: controller.signal, diff --git a/tests/unit/opencode-provider-config.test.ts b/tests/unit/opencode-provider-config.test.ts index cf01bc5..2aebc67 100644 --- a/tests/unit/opencode-provider-config.test.ts +++ b/tests/unit/opencode-provider-config.test.ts @@ -341,6 +341,7 @@ describe('buildOpencodeRuntimeConfig', () => { }); it('routes imported Works Square models through the local AI proxy without gateway-token headers', async () => { + const resolveApiKey = vi.fn().mockResolvedValue('stale-host-api-token'); const result = await buildOpencodeRuntimeConfig({ accounts: [ createAccount({ @@ -358,7 +359,8 @@ describe('buildOpencodeRuntimeConfig', () => { }), ], defaultAccountId: 'niancode-user-models', - resolveApiKey: vi.fn().mockResolvedValue('local-host-api-token'), + resolveApiKey, + localProxyApiKey: 'current-host-api-token', }); expect(result.config.provider['niancode-user-models'].options).toEqual({ @@ -368,8 +370,9 @@ describe('buildOpencodeRuntimeConfig', () => { expect(JSON.stringify(result.config)).not.toContain('X-Works-Square-AI-Token'); expect(JSON.stringify(result.config)).not.toContain('https://open-api.example.com'); expect(result.env).toEqual({ - NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'local-host-api-token', + NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'current-host-api-token', }); + expect(resolveApiKey).not.toHaveBeenCalled(); }); it('strips DeepSeek routing prefixes from imported Works Square runtime models', async () => { diff --git a/tests/unit/opencode-routes.test.ts b/tests/unit/opencode-routes.test.ts index 41feb48..bac88ff 100644 --- a/tests/unit/opencode-routes.test.ts +++ b/tests/unit/opencode-routes.test.ts @@ -3588,6 +3588,7 @@ description: Browser debugging. { opencodeManager: { getStatus: () => runtimeStatus, + getRuntimeGenerationProvenance: () => 'fresh', restart, }, opencodeProjectStore: { @@ -4029,13 +4030,15 @@ description: Browser debugging. updatedAt: '2026-07-06T00:00:00.000Z', }; providerServiceMock.getAccount.mockResolvedValue(existingAccount); - providerServiceMock.getAccountApiKey.mockResolvedValue('old-host-api-token'); + let storedApiKey = 'old-host-api-token'; + providerServiceMock.getAccountApiKey.mockImplementation(async () => storedApiKey); let releaseRebind!: () => void; const rebindGate = new Promise((resolve) => { releaseRebind = resolve; }); - providerServiceMock.updateAccount.mockImplementationOnce(async () => { + providerServiceMock.updateAccount.mockImplementationOnce(async (_accountId, _account, apiKey) => { await rebindGate; + storedApiKey = apiKey; return existingAccount; }); buildConfigSummaryMock.mockResolvedValue({ @@ -4109,18 +4112,13 @@ description: Browser debugging. 'current-host-api-token', ); 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(sameGenerationResponse.statusCode).toBe(409); - expect(promptSessionAsync).not.toHaveBeenCalled(); - providerServiceMock.getAccountApiKey.mockResolvedValue('current-host-api-token'); + expect(promptSessionAsync).toHaveBeenCalledTimes(2); + expect(response.statusCode).toBe(202); + expect(sameGenerationResponse.statusCode).toBe(202); runtimeGeneration = 2; provenance = 'attached'; + storedApiKey = 'old-host-api-token'; const attachedResponse = createResponse(); await handleOpencodeRoutes( createRequest('POST', { text: 'Attached is not authoritative' }), @@ -4129,10 +4127,11 @@ description: Browser debugging. context, ); expect(attachedResponse.statusCode).toBe(409); - expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(promptSessionAsync).toHaveBeenCalledTimes(2); runtimeGeneration = 3; provenance = 'fresh'; + storedApiKey = 'current-host-api-token'; const freshResponse = createResponse(); await handleOpencodeRoutes( createRequest('POST', { text: 'Fresh generation' }), @@ -4141,7 +4140,7 @@ description: Browser debugging. context, ); expect(freshResponse.statusCode).toBe(202); - expect(promptSessionAsync).toHaveBeenCalledOnce(); + expect(promptSessionAsync).toHaveBeenCalledTimes(3); }); it('fails closed when rebinding the local proxy Host API token is rejected', async () => { diff --git a/tests/unit/provider-routes.test.ts b/tests/unit/provider-routes.test.ts index d5a2bfd..9681078 100644 --- a/tests/unit/provider-routes.test.ts +++ b/tests/unit/provider-routes.test.ts @@ -412,68 +412,76 @@ describe('provider host api routes', () => { ); }); - it('restarts a running runtime when the local proxy Host API token changed', async () => { - const existingAccount = createProviderAccount({ - 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: 'gpt-4.1-mini', - fallbackModels: ['gpt-4o-mini'], - enabled: true, - isDefault: true, - metadata: { - customModels: ['gpt-4.1-mini', 'gpt-4o-mini'], - worksSquareCredentialMode: 'works_square_ai_gateway_proxy', - worksSquareOneApiBaseUrl: 'https://one-api.example.com/v1', - }, - }); - providerServiceMock.getAccount.mockResolvedValueOnce(existingAccount); - providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-host-api-token'); - getHostApiTokenMock.mockReturnValueOnce('new-host-api-token'); - providerServiceMock.updateAccount.mockResolvedValueOnce(existingAccount); - const fetchMock = vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ - provider_type: 'openai-compatible', + it.each([ + ['fresh', false], + ['attached', true], + ] as const)( + '%s runtime handles a changed local proxy Host API token without losing freshness guarantees', + async (provenance, runtimeRefreshRequired) => { + const existingAccount = createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', 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', 'gpt-4o-mini'], - }), { status: 200 }), - ); - vi.stubGlobal('fetch', fetchMock); - const response = createResponse(); - const restart = vi.fn(async () => ({ state: 'running', port: 4096, pid: 4242 })); - - 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, - ); - - expect(response.statusCode).toBe(200); - expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( - 'niancode-user-models', - expect.objectContaining({ + authMode: 'api_key', baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', - metadata: expect.objectContaining({ + apiProtocol: 'openai-completions', + model: 'gpt-4.1-mini', + fallbackModels: ['gpt-4o-mini'], + enabled: true, + isDefault: true, + metadata: { + customModels: ['gpt-4.1-mini', 'gpt-4o-mini'], worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + worksSquareOneApiBaseUrl: 'https://one-api.example.com/v1', + }, + }); + providerServiceMock.getAccount.mockResolvedValueOnce(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-host-api-token'); + getHostApiTokenMock.mockReturnValueOnce('new-host-api-token'); + providerServiceMock.updateAccount.mockResolvedValueOnce(existingAccount); + const fetchMock = 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', 'gpt-4o-mini'], + }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + const restart = vi.fn(async () => ({ state: 'running', port: 4096, pid: 4242 })); + + 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 }), + getRuntimeGenerationProvenance: () => provenance, + restart, + }, + } as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ runtimeRefreshRequired }); + expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( + 'niancode-user-models', + expect.objectContaining({ + baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', + metadata: expect.objectContaining({ + worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + }), }), - }), - 'new-host-api-token', - ); - expect(restart).toHaveBeenCalledOnce(); - }); + 'new-host-api-token', + ); + expect(restart).toHaveBeenCalledTimes(runtimeRefreshRequired ? 1 : 0); + }, + ); it('defers a direct API-key rotation without restarting and reports the runtime refresh requirement', async () => { const existingAccount = createProviderAccount({