From 65040730ec229436b27e0e1a34ca16599b534996 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 17 Aug 2026 22:03:37 +0800 Subject: [PATCH] fix: isolate concurrent OpenCode chat runs --- .../20260817-multichat-runtime-fix-f3a91c.md | 103 ++ README.md | 3 +- electron/api/routes/opencode.ts | 497 ++++- electron/api/routes/providers.ts | 171 +- electron/opencode/client.ts | 29 +- electron/opencode/manager.ts | 19 + electron/opencode/project-agent-runtime.ts | 319 ++++ electron/opencode/project-config.ts | 98 +- electron/opencode/runtime-config-readiness.ts | 107 ++ src/components/settings/ProvidersSettings.tsx | 31 +- src/pages/Chat/OpencodeChatPanel.tsx | 76 +- src/pages/Makelore/index.tsx | 21 +- src/stores/opencode-session-run-machine.ts | 3 + src/stores/opencode.ts | 234 ++- src/stores/providers.ts | 15 +- tests/e2e/opencode-multichat-runtime.spec.ts | 339 ++++ tests/unit/login-page.test.tsx | 5 +- tests/unit/makelore-character-scene.test.tsx | 60 + tests/unit/opencode-chat-panel.test.tsx | 247 ++- tests/unit/opencode-client.test.ts | 56 + tests/unit/opencode-manager.test.ts | 38 + tests/unit/opencode-routes.test.ts | 1621 +++++++++++++++-- .../unit/opencode-session-run-machine.test.ts | 31 +- tests/unit/opencode-store.test.ts | 567 +++++- tests/unit/project-agent-runtime.test.ts | 336 ++++ tests/unit/project-config.test.ts | 43 + tests/unit/provider-routes.test.ts | 482 ++++- tests/unit/provider-store-validation.test.ts | 46 +- tests/unit/providers-settings.test.tsx | 37 +- 29 files changed, 5207 insertions(+), 427 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260817-multichat-runtime-fix-f3a91c.md create mode 100644 electron/opencode/project-agent-runtime.ts create mode 100644 electron/opencode/runtime-config-readiness.ts create mode 100644 tests/e2e/opencode-multichat-runtime.spec.ts create mode 100644 tests/unit/project-agent-runtime.test.ts diff --git a/.project-docs/30-worklog/tasks/20260817-multichat-runtime-fix-f3a91c.md b/.project-docs/30-worklog/tasks/20260817-multichat-runtime-fix-f3a91c.md new file mode 100644 index 0000000..081295d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260817-multichat-runtime-fix-f3a91c.md @@ -0,0 +1,103 @@ +# Task: Fix concurrent chat Agent registry stall + +## Identity + +- Task ID: 20260817-multichat-runtime-fix-f3a91c +- Mode: Feature +- Branch: codex/20260817-multichat-runtime-fix-f3a91c-multichat-runtime-fix +- Worktree: D:\Datas\OthersProjects\makelore-multichat-runtime-fix-f3a91c +- Base commit: 7e8d9e38114158992c03e589de32535274796d04 +- Owner: codex-root +- Status: Ready for integration + +## Scope + +- Add a Main-owned project Agent readiness barrier before project-scoped OpenCode prompts. +- Track the generated Makelore Agent set by runtime generation and content fingerprint so a same-id content change cannot be mistaken for an applied runtime update. +- Verify the selected Agent against the live OpenCode registry before `prompt_async`; return a typed terminal response without sending when the runtime registry is stale. +- Make Renderer prompt startup acknowledgement session-scoped and bounded: only explicit busy state, assistant output, or a typed terminal event confirms/ends startup; a persisted user message alone does not. +- Add focused regression coverage for concurrent sessions, stale/new Agent handling, runtime generation rollover, managed-file preservation, and per-session startup failure cleanup. +- Do not patch or fork the bundled OpenCode executable in this task. + +## Intent And Constraints + +- Preserve a running Session A while Session B is submitted; no application-wide reply-duration lock. +- OpenCode 1.18.9 exposes no authoritative whole-instance quiescence oracle. Automatic `/instance/dispose`, reload, or runtime restart must not be used as Agent refresh, even when `/session/status` appears idle. +- A prompt-start acknowledgement must not be inferred from the user prompt merely appearing in session history. If no explicit busy/assistant/error signal appears within the bounded window, terminalize only that session as `SESSION_START_UNCONFIRMED`; do not replay automatically. +- Only a typed preflight failure that guarantees `prompt_async` was never called may retain a cancelable same-process pending intent. Network failures and unconfirmed starts are terminal and must never be auto-replayed. +- Generated Agent readiness is bound to `{runtimeGeneration, desiredFingerprint, appliedFingerprint}`. `GET /agent` verifies live id presence but cannot prove same-id content freshness. +- The managed fingerprint covers relative path plus generated content hash. Preserve non-Makelore files in `.opencode/agent`; remove only files known to have been generated by Makelore configuration. +- Keep Renderer backend access through existing Host API modules and keep Electron Main responsible for runtime/configuration synchronization. +- Avoid paid provider calls in automated verification; use unit/integration doubles and perform only local-runtime smoke checks when available. +- Completion requires focused tests, typecheck, lint, full unit tests, `build:vite`, and a final read-only Sol reviewer PASS. + +## Outcome + +- Added a Main-owned project Agent readiness barrier keyed by runtime generation and the generated Agent manifest fingerprint. A prompt is rejected before `prompt_async` with typed `OPENCODE_AGENT_REGISTRY_PENDING` when the live registry cannot prove the desired Agent set was loaded for the active runtime generation. +- Runtime generations carry explicit `unknown` / `starting` / `fresh` / `attached` provenance. Only a fresh owned-runtime generation may bind the desired fingerprint as applied; attached and unknown runtimes fail closed even when the live registry contains the same Agent id. +- Preserved custom and user-modified `.opencode/agent` files by reading the previous project config without materialization side effects before retirement checks. Only retired Makelore-managed files whose on-disk content still matches the previously generated content are removed. +- A prompt that finds stale provider/runtime configuration now returns typed `OPENCODE_RUNTIME_CONFIG_PENDING` with `promptSent:false` before sending. The prompt path never restarts the shared runtime; explicit manual lifecycle actions retain their existing behavior. +- Legacy/direct Works credential import supports a deferred runtime-refresh mode. Prompt submission persists a refreshed credential but returns the same typed pending response when the active runtime must reload; it neither restarts nor calls `prompt_async`. The requirement is remembered for the active runtime generation until a fresh generation appears. +- Automatic Works model synchronization from ordinary messages, project commands, and context summarization now explicitly uses deferred refresh. If the active runtime must reload, Renderer preserves the draft, shows a manual-restart instruction, and does not submit the execution request. +- Runtime configuration freshness is latched by manager identity and runtime generation for direct API-key rotation, local-proxy Host-token rebinding, and runtime-affecting persistence/rebind failures. Repeated requests in the same generation remain terminally rejected; only a later owned `fresh` generation clears the latch. A pure lock-external fetch/auth failure does not poison an otherwise current generation and may be retried manually. +- Provider runtime-affecting persistence and message/command/summarize acceptance are linearized by one manager-scoped FIFO coordinator. The coordinator is held only through runtime HTTP acceptance, not the model's reply duration, so it closes credential/config races without reintroducing a global reply lock. +- Direct-credential refresh coalescing is scoped by manager identity. A stopped manager cannot lend a no-refresh result to a different running manager. +- Runtime registry/config inspection and message, command, and summarize acceptance carry one abort signal with a hard 10-second bound. Timeout releases both manager and project Agent critical sections; the Works model-config fetch and response-body read use the same bounded pattern before entering the manager coordinator. +- Timed-out runtime-config leases are revoked. A late non-cooperative continuation cannot mutate readiness or write a second response; an uncertain timeout retains a sticky latch across ordinary fresh restarts until a later successful explicit apply owns a new fresh generation. +- Manual runtime start, stop, and restart share the same manager FIFO as provider persistence. A lifecycle action therefore cannot capture partially persisted credentials, and an explicit apply consumes an existing deferred latch even when the persisted provider values already match. +- Project Agent observation, live-registry reads, and acceptance share the execution AbortSignal from lock acquisition onward. Cancelling while queued releases that request's FIFO tail, prevents late state observation/runtime calls, and leaves subsequent project mutations unblocked. +- Project Agent configuration mutation and Agent registry preflight plus runtime acceptance share one per-project critical section. A concurrent config save cannot complete between a successful live-registry check and `prompt_async` or Agent-scoped command acceptance. +- The no-automatic-restart boundary now covers ordinary messages, project commands, and context summarization. Agent-scoped commands use the same atomic readiness barrier as messages. +- Model-page and Provider-settings background synchronization also explicitly uses deferred refresh. Only clearly labeled user-triggered apply actions may restart the runtime; successful apply clears any pending UI even when the response reports that a refresh was required. +- Added a 10-second, run-scoped prompt startup confirmation window. Host acceptance and a persisted user prompt no longer count as start acknowledgement; explicit busy/retry state, assistant output, question, permission, or a typed terminal event does. +- The startup window is enforced by an independent `{sessionId, runToken}` watchdog after Host POST success. It races status polling, message polling, and polling sleeps, so a hung read cannot extend the deadline; late responses and events cannot write after the run token is invalidated. +- An unconfirmed start now terminates only the affected session with stable `SESSION_START_UNCONFIRMED`, clears only that session's sending/stream state, and never auto-replays its prompt. Other running sessions remain intact. +- Any uncertain remote failure clears that Session's internal queued prompts. A later manual retry sends only the new prompt and cannot replay a prompt queued before the failure; other Sessions' queues and running state are unchanged. +- Session run failures are stored only in `sessionRunStates[sessionId]`; the top-level error is reserved for genuine global errors. Chat projection prefers a genuine global error and otherwise uses the selected Session error, without comparing strings for provenance. +- Added an Electron E2E regression in which Session A stays busy while Session B receives the typed Agent-registry rejection; B terminates without retry, its draft/error persist, and A's loader/state remain active. +- Kept runtime reload/restart/dispose manual. The implementation does not infer whole-instance quiescence from session status. + +## Verification + +- Initial independent Sol review: FAIL. It identified attached-runtime generation provenance, side-effectful retired-file cleanup, prompt-path automatic restart, error provenance collision, and README concurrency wording as blockers. All five findings were remediated with focused regressions; a second independent review is pending. +- Second independent Sol review: FAIL. It found a separate legacy credential-import restart path, the config PUT route's remaining side-effectful baseline read, a polling-dependent rather than hard 10-second watchdog, and an overly permissive `starting` provenance. All four findings were remediated with focused regressions; a final re-review is pending. +- Third independent Sol review: FAIL. It found a Renderer pre-send provider refresh that could still restart the shared runtime, incomplete generation-latched credential freshness, queued-prompt replay after uncertain startup failure, and a preflight-to-send race between Agent config writes and prompt acceptance. It also identified `/command` and `/summarize` as shared-runtime execution paths requiring the same no-automatic-restart boundary. All findings were remediated with focused regressions; a new read-only re-review is pending. +- Fourth independent Sol review: FAIL. It found that runtime-config mutation/latching and final message/command/summarize acceptance were not yet linearized under one manager-scoped coordinator, that two other Renderer background synchronization effects still used automatic `apply`, that direct-credential refresh promises were shared across managers, and that an `apply` partial failure could persist new credentials without retaining a stale-runtime latch. It also identified unbounded runtime HTTP inside the Agent critical section. All findings were remediated with controlled interleaving, cross-manager, partial-failure, and timeout/liveness regressions; a new final read-only review is pending. +- Fifth independent Sol review: FAIL. Although its independent 13-file run passed 471/471 tests, it found four remaining coordination gaps: an explicit `apply` after a prior deferred persistence skipped restart because the persisted values already matched; the 10-second `Promise.race` released the manager lease while a non-cooperative late operation could still mutate readiness or write a second HTTP response; manual runtime lifecycle routes did not share the manager FIFO and could start a stale-but-`fresh` generation during delayed persistence; and a non-cooperative `listAgents` call could hold the project lock forever after the manager timeout. Remediation must make explicit apply consume the pending latch, serialize lifecycle with persistence, revoke timed-out leases and suppress all late side effects/responses, and give registry inspection the same abort race as acceptance before another final review. +- Fifth-remediation Main focused tests: 4 files, 162/162 passed. They include controlled defer-to-apply, delayed-persistence versus lifecycle, sticky uncertain-latch, original AbortError propagation, late response suppression, non-cooperative registry/config/rebind operations, and abort-while-queued project-lock regressions. Typecheck, scoped lint, `build:vite`, diff check, and project-doc drift also passed after the final signal-checkpoint hardening. +- Unified regression set after fifth remediation: 13 files, 487/487 passed. +- Full unit suite after fifth remediation: 176 files, 2098/2098 passed. +- Full ESLint after fifth remediation passed with zero errors and the same seven pre-existing warnings. +- Focused Electron E2E `opencode-multichat-runtime.spec.ts` after final FIFO hardening: 1/1 passed in 6.6 seconds. +- The first sixth-review pass was interrupted when main-thread inspection found that an aborted project-lock waiter deleted its queued tail before the active predecessor released, allowing a later mutation to bypass mutual exclusion. A RED A-held/B-abort/C-must-wait interleaving reproduced the bypass; cleanup now retains the cancelled tail until its predecessor chain settles, and the regression passes while also proving later progress. +- Sixth independent Sol review: FAIL. Standards passed, but Spec found that a sticky uncertain latch survived a successful explicit provider apply while the runtime was stopped: the route reported success, Renderer cleared its pending UI, and the next fresh start still could not clear the sticky latch, so execution remained terminally rejected. Remediation must convert the sticky latch to clear-on-next-fresh only after the stopped-runtime explicit apply fully persists the account/default selection, and must cover `sticky -> stop -> apply -> start fresh -> execution accepted` before another final review. +- Sixth-remediation Main focused tests: 4 files, 164/164 passed. The complete stopped-runtime recovery sequence now ends with a 202 message after the next fresh start without calling restart during apply; a failed default-account persistence remains sticky across a later fresh generation. +- Final full unit suite: 176 files, 2100/2100 passed. +- Final focused Electron E2E: 1/1 passed in 10.4 seconds. +- Seventh independent Sol review: PASS. Standards and Spec both found no blockers. It independently confirmed stopped sticky recovery, timeout/late-continuation safety, single-response protection, manager/project FIFO ordering including A-held/B-abort/C-must-wait, Agent signal propagation, Renderer defer/apply behavior, and the absence of a reply-duration global lock or automatic runtime restart/dispose. +- Main remediation tests: 152/152 passed; routes were also rerun independently at 91/91. +- Renderer error-provenance tests: 224/224 passed. +- Unified focused unit set after second remediation: 9 files, 429/429 passed. +- Full unit suite after second remediation: 176 files, 2067/2067 passed. +- Third-remediation Main focused tests: 112/112 passed, including controlled Agent config-write versus runtime-acceptance interleaving. +- Third-remediation Renderer automatic-sync tests: 98/98 passed; uncertain-failure queue tests: 149/149 passed. +- Unified focused regression set after third remediation: 10 files, 446/446 passed. +- Full unit suite after third remediation: 176 files, 2074/2074 passed. +- Fourth-remediation Main focused tests: 4 files, 145/145 passed; background-sync Renderer tests: 2 files, 7/7 passed. +- Unified focused regression set after fourth remediation: 13 files, 471/471 passed. +- Full unit suite after fourth remediation: 176 files, 2081/2081 passed. +- TypeScript typecheck passed. +- Scoped ESLint passed; full lint passed with seven pre-existing warnings in unrelated files. +- `build:vite` passed with existing dynamic-import and chunk-size warnings only. +- Focused Electron E2E `opencode-multichat-runtime.spec.ts` after fourth remediation: 1/1 passed in 6.9 seconds. +- `git diff --check` passed; PowerShell reported line-ending conversion warnings only. +- The opt-in real bundled OpenCode smoke was safely skipped because `NIANCODE_OPENCODE_REAL_SMOKE=1` was not configured. No paid provider call was made, so actual provider/runtime two-session execution is not claimed by this task. + +## Follow-ups + +- Run the opt-in real bundled OpenCode two-session smoke with an explicitly configured test provider to establish whether the upstream runtime/provider truly executes two model turns concurrently. The current tests establish application-side isolation, bounded failure, and no replay, not upstream concurrency. +- If immediate Agent hot reload is required, first add an upstream directory-scoped authoritative invalidation API or a whole-instance quiescence oracle. Until then, edit/new Agent changes require a manual runtime restart after active replies finish. + +## Promotion Candidates + +- Target: `.project-docs/20-architecture/data-flow.md`. Proposal: document the Main-owned Agent readiness flow (`project.json` write -> desired fingerprint -> runtime generation bootstrap -> live `/agent` id verification -> prompt preflight) and Renderer's per-session startup acknowledgement/terminalization contract. Evidence: focused Main/Renderer unit suites plus `opencode-multichat-runtime.spec.ts`. Expected impact: future prompt, runtime lifecycle, and Agent configuration changes retain the no-replay and no-automatic-dispose safety boundary. No known conflict; human confirmation is not required because this records implemented architecture. diff --git a/README.md b/README.md index 729bcac..27db7a5 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(); }); }); });