From 8bb386f7022e860734a14f543fbf117378b88142 Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Tue, 3 Nov 2026 18:11:40 +0800 Subject: [PATCH] [feat] assistant: load harnesses independently and refine controls Signed-off-by: Weiming Liao --- apps/api/src/opendirector_api/agent.py | 45 ++++++++------ docs/architecture/agent-runtime.md | 2 + .../core/src/opendirector/agent/runtime.py | 10 ++-- src/backend/chat.ts | 20 ++++++- src/canvas/AgentPicker.tsx | 45 +++++++++++--- src/canvas/AssistantPanel.tsx | 11 +++- src/canvas/agent-picker.css | 19 ++++-- src/canvas/assistant-panel.css | 19 ++++-- src/canvas/model-settings.css | 10 ---- src/canvas/settings.css | 10 ++++ src/canvas/useAssistantChat.ts | 60 +++++++++++++------ tests/chatRuntimes.test.ts | 35 +++++++++++ tests/test_runtime_readiness.py | 31 +++++++++- 13 files changed, 245 insertions(+), 72 deletions(-) create mode 100644 tests/chatRuntimes.test.ts diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index be333e92..092cf0ad 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -1,6 +1,6 @@ """Project-scoped assistant persistence and native runtime streaming.""" import asyncio -from contextlib import suppress +from contextlib import aclosing, suppress import json import logging from pathlib import Path @@ -14,7 +14,7 @@ from opendirector.agent.catalog import model_choices from opendirector.agent.context import RunContext, RuntimeId -from opendirector.agent.runtime import AgentRuntimes, runtime_available, configured_runtimes, runtime_spec +from opendirector.agent.runtime import AgentRuntimes, runtime_available, configured_runtimes, runtime_spec, runtime_catalog, runtime_readiness from opendirector.agent.skills import skills from opendirector.domain.projects import ProjectError from opendirector.infrastructure.creation_projects import durable_json @@ -180,9 +180,19 @@ async def draft(project_id: UUID, value: Draft, x_opendirector_session: UUID = H return await asyncio.to_thread(conversations.update, project_id, str(x_opendirector_session), draft=value) @router.get('/runtimes') - async def available_runtimes(project_id: UUID): + async def available_runtimes(project_id: UUID, probe: bool = True): await asyncio.to_thread(conversations.path, project_id) - return await configured_runtimes() + if probe: + return await configured_runtimes() + return [{**item, 'status': 'not_installed' if not item['available'] else + 'ready' if item['id'] in {'default', 'opencode'} else 'checking'} + for item in runtime_catalog()] + + @router.get('/runtimes/{runtime}/status') + async def runtime_status(project_id: UUID, runtime: Runtime): + await asyncio.to_thread(conversations.path, project_id) + item = next(item for item in runtime_catalog() if item['id'] == runtime) + return {**item, 'status': await runtime_readiness(runtime)} @router.get('/runtimes/{runtime}/models') async def runtime_models(project_id: UUID, runtime: Literal['opencode', 'codex', 'claude', 'jiuwenswarm']): @@ -256,19 +266,20 @@ async def produce() -> None: session=session, directory=directory, config=config, messages=context, selected_skills=value.skills, thinking=value.thinking, runtime_model=value.runtimeModel, skills=available) - async for event in runtimes.stream(run): - if event['type'] == 'delta': - text += event['text'] - if event['type'] == 'tool': - previous = next((item for item in activities if item['id'] == event['id']), None) - activity = {**{key: item for key, item in event.items() if key != 'type'}, - 'textOffset': previous['textOffset'] if previous else len(text)} - if previous is None: - activities.append(activity) - else: - activities[activities.index(previous)] = activity - event = {'type': 'tool', **activity} - await queue.put(event) + async with aclosing(runtimes.stream(run)) as stream: + async for event in stream: + if event['type'] == 'delta': + text += event['text'] + if event['type'] == 'tool': + previous = next((item for item in activities if item['id'] == event['id']), None) + activity = {**{key: item for key, item in event.items() if key != 'type'}, + 'textOffset': previous['textOffset'] if previous else len(text)} + if previous is None: + activities.append(activity) + else: + activities[activities.index(previous)] = activity + event = {'type': 'tool', **activity} + await queue.put(event) except asyncio.CancelledError: status = 'stopped' raise diff --git a/docs/architecture/agent-runtime.md b/docs/architecture/agent-runtime.md index 01c7a643..f833c172 100644 --- a/docs/architecture/agent-runtime.md +++ b/docs/architecture/agent-runtime.md @@ -12,6 +12,7 @@ Harness 指承担模型调用、工具执行和上下文管理的 Agent 执行 | OpenCode CLI | PATH 中的 opencode | CLI 原生配置与模型目录 | | Codex | PATH 中的 codex | 原生认证与模型目录;隔离启动前解析所选模型或默认模型 | | Claude Code | PATH 中的 claude | 原生认证、环境变量与初始化模型目录 | +| JiuwenSwarm | PATH 中的 jiuwenswarm-process,通过 stdin/stdout JSONL 直接调用 | 本机 Jiuwen 模型目录、配置与环境变量 | Default 与 OpenCode CLI 分别配置。外部 CLI 不读取 OpenDirector 保存的模型凭证。进程继承服务端环境,包括 PATH 和第三方 API 配置。 @@ -58,5 +59,6 @@ Default 将模型配置中的输入模态传入 bundled OpenCode。外部 harnes | OpenCode | 工具权限配置开放 Shell、文件读取、已发现技能和业务工具 | | Codex | workspace-write 沙箱,开放 Shell 和业务工具 | | Claude Code | 明确允许 Skill、Bash、Read 和业务工具 | +| JiuwenSwarm | 明确允许 skill_tool、list_skill、read_file、bash 和业务工具 | 工具允许列表与操作系统沙箱提供不同约束。业务工具独立校验工程范围和输入;原生文件访问权限不授予直接改写工程存储的业务权限。 diff --git a/packages/core/src/opendirector/agent/runtime.py b/packages/core/src/opendirector/agent/runtime.py index dfab52d7..814bd805 100644 --- a/packages/core/src/opendirector/agent/runtime.py +++ b/packages/core/src/opendirector/agent/runtime.py @@ -2,6 +2,7 @@ import asyncio import json from collections.abc import AsyncIterator +from contextlib import aclosing from dataclasses import asdict, dataclass, replace from typing import Literal @@ -27,10 +28,10 @@ class RuntimeSpec: RUNTIMES = { 'default': RuntimeSpec(id='default', label='Default', group='default', modelSource='settings', modelOverride=False, engine='default'), - 'opencode': RuntimeSpec(id='opencode', label='OpenCode CLI', group='harness', modelSource='cli', modelOverride=True, engine='opencode'), + 'opencode': RuntimeSpec(id='opencode', label='OpenCode', group='harness', modelSource='cli', modelOverride=True, engine='opencode'), + 'jiuwenswarm': RuntimeSpec(id='jiuwenswarm', label='JiuwenSwarm', group='harness', modelSource='cli', modelOverride=True, engine='jiuwenswarm'), 'codex': RuntimeSpec(id='codex', label='Codex', group='harness', modelSource='cli', modelOverride=True, engine='codex'), 'claude': RuntimeSpec(id='claude', label='Claude Code', group='harness', modelSource='cli', modelOverride=True, engine='claude'), - 'jiuwenswarm': RuntimeSpec(id='jiuwenswarm', label='JiuwenSwarm', group='harness', modelSource='cli', modelOverride=True, engine='jiuwenswarm'), } @@ -107,7 +108,8 @@ async def stream(self, run: RunContext) -> AsyncIterator[AgentEvent]: gateway = ToolGateway(self.business, run) try: adapter = self.jiuwenswarm if run.runtime == 'jiuwenswarm' else self.adapter - async for event in adapter.stream(run, gateway): - yield event + async with aclosing(adapter.stream(run, gateway)) as stream: + async for event in stream: + yield event finally: await gateway.close() diff --git a/src/backend/chat.ts b/src/backend/chat.ts index 77fe51b3..c53015ae 100644 --- a/src/backend/chat.ts +++ b/src/backend/chat.ts @@ -15,6 +15,7 @@ export interface RuntimeInfo { modelOverride: boolean; available: boolean; status: + | "checking" | "ready" | "not_installed" | "needs_login" @@ -124,8 +125,23 @@ export const activateConversation = ( (create ? "/conversations" : `/conversations/${id}/activate`), create ? json("POST", { id }) : json("POST", {}), ); -export const chatRuntimes = (id: string) => - request(path(id) + "/runtimes"); +export const chatRuntimeStatus = (id: string, runtime: AgentRuntime) => + request(path(id) + `/runtimes/${runtime}/status`); + +export async function chatRuntimes(id: string, onUpdate?: (items: RuntimeInfo[]) => void) { + const rows = await request(path(id) + "/runtimes?probe=false"); + onUpdate?.([...rows]); + await Promise.all(rows.map(async (item, index) => { + if (item.status !== "checking") return; + try { + rows[index] = await chatRuntimeStatus(id, item.id); + } catch { + rows[index] = {...item, status: "unknown"}; + } + onUpdate?.([...rows]); + })); + return rows; +} export const conversation = (id: string) => request(path(id)); export const saveChatDraft = (id: string, draft: ChatDraft) => request(path(id) + "/draft", json("PUT", draft)); diff --git a/src/canvas/AgentPicker.tsx b/src/canvas/AgentPicker.tsx index c7a325da..d18d43d3 100644 --- a/src/canvas/AgentPicker.tsx +++ b/src/canvas/AgentPicker.tsx @@ -24,6 +24,7 @@ export function AgentPicker({ models, runtimes, disabled, + initializing = false, onChange, onConfigure, onRefresh, @@ -33,9 +34,10 @@ export function AgentPicker({ models: ModelConfig[]; runtimes: RuntimeInfo[]; disabled: boolean; + initializing?: boolean; onChange: (patch: Partial) => void; onConfigure: () => void; - onRefresh: () => Promise; + onRefresh: (runtime?: AgentRuntime) => Promise; }) { const harnesses = runtimes.filter( (item): item is RuntimeInfo & { id: Harness } => @@ -46,6 +48,7 @@ export function AgentPicker({ const [setup, setSetup] = useState(null); const [checking, setChecking] = useState(false); const [checkError, setCheckError] = useState(""); + const [commandCopied, setCommandCopied] = useState(false); const [catalogs, setCatalogs] = useState< Partial> >({}); @@ -72,7 +75,9 @@ export function AgentPicker({ (item.modelSource !== "settings" || options.length > 0); const setupSpec = harnesses.find((item) => item.id === setup); const reason = (item: RuntimeInfo) => - item.status === "not_installed" + item.status === "checking" + ? "检查中…" + : item.status === "not_installed" ? "尚未安装" : item.status === "needs_login" ? "尚未登录" @@ -110,8 +115,10 @@ export function AgentPicker({ const editingSpec = harnesses.find((item) => item.id === editing); const runtime = value.runtime ?? "default"; const harness = harnesses.find((item) => item.id === runtime); - const title = !runtimes.some((item) => item.id === runtime) - ? "重新选择模型" + const title = initializing + ? "正在加载引擎…" + : !runtimes.some((item) => item.id === runtime) + ? "重新选择模型" : runtime === "default" ? (agent?.modelId ?? "选择模型") : harness?.modelSource === "settings" @@ -140,7 +147,8 @@ export function AgentPicker({ if ( item.id === "default" || item.modelSource !== "cli" || - item.status !== "ready" + item.status !== "ready" || + !!catalogs[item.id] ) continue; const id = item.id; @@ -317,6 +325,7 @@ export function AgentPicker({ {open && !disabled && (

在终端执行:

- {setupCommand} +
+ {setupCommand} + +
)} @@ -375,7 +403,7 @@ export function AgentPicker({ setChecking(true); setCheckError(""); try { - await onRefresh(); + await onRefresh(setupSpec.id); } catch { setCheckError("检查失败,请确认本地服务正在运行。"); } finally { @@ -420,6 +448,7 @@ export function AgentPicker({ if (ready(item)) chooseHarness(item.id); else { setEditing(null); + setCommandCopied(false); setSetup(item.id); } }} @@ -439,7 +468,7 @@ export function AgentPicker({ aria-expanded={editing === item.id} onClick={() => edit(item.id)} > - 编辑 + Edit )}
diff --git a/src/canvas/AssistantPanel.tsx b/src/canvas/AssistantPanel.tsx index 97c9dace..8bcc7a2f 100644 --- a/src/canvas/AssistantPanel.tsx +++ b/src/canvas/AssistantPanel.tsx @@ -610,7 +610,7 @@ export function AssistantPanel({ disabled={referenceDisabled || value.assets.length >= 64} onClick={() => fileInput.current?.click()} > - + @@ -692,7 +693,11 @@ export function AssistantPanel({

- {chat.adding ? ( + {!chat.loaded ? ( + chat.error ? "对话加载失败" : "正在加载对话…" + ) : chat.runtimes.find((item) => item.id === (value.runtime ?? "default"))?.status === "checking" ? ( + "正在检查当前引擎…" + ) : chat.adding ? ( "正在添加参考素材…" ) : ( `${chat.runtimes.find((item) => item.id === (value.runtime ?? "default"))?.label ?? "请选择 Agent"}` diff --git a/src/canvas/agent-picker.css b/src/canvas/agent-picker.css index 7fba20de..777b3669 100644 --- a/src/canvas/agent-picker.css +++ b/src/canvas/agent-picker.css @@ -1,4 +1,3 @@ -.cw-agent-picker .chat-picker-trigger { background: var(--chat-surface); } .cw-agent-menu { position: absolute; z-index: 90; left: 0; bottom: calc(100% + 10px); width: min(330px, calc(100cqw - 96px)); max-height: min(480px, 65dvh); overflow: visible; @@ -6,7 +5,7 @@ background: var(--chat-background); color: var(--chat-text); box-shadow: 0 10px 36px #0005; } .cw-agent-menu h3 { margin: 6px 8px 8px; font-size: 11px; font-weight: 500; color: var(--chat-muted); } -.cw-agent-row { display: flex; align-items: center; gap: 3px; } +.cw-agent-row { display: flex; align-items: center; gap: 3px; border-radius: 7px; } .cw-assistant-panel .cw-agent-row > button[role] { display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px; padding: 10px 8px; color: var(--chat-text); text-align: left; border-radius: 7px; @@ -14,8 +13,14 @@ .cw-agent-row strong { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cw-agent-row small { margin-left: auto; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--chat-muted); font-size: 11px; } .cw-agent-row svg { width: 14px; height: 14px; flex-shrink: 0; } -.cw-assistant-panel .cw-agent-edit { padding: 7px 5px; color: var(--chat-muted); font-size: 11px; border-radius: 6px; } -.cw-agent-row button:hover, .cw-agent-row button:focus-visible, .cw-agent-row [aria-checked="true"] { background: var(--chat-hover); } +.cw-assistant-panel .cw-agent-edit { padding: 10px 8px; color: var(--chat-muted); font-size: 11px; border-radius: 7px; opacity: 0; text-underline-offset: 2px; } +.cw-agent-row:has(> [aria-checked="true"]), .cw-agent-row:hover, .cw-agent-row:focus-within { background: var(--chat-hover); } +.cw-agent-row:has(> [aria-checked="true"]) > .cw-agent-edit, +.cw-agent-row:hover > .cw-agent-edit, +.cw-agent-row:focus-within > .cw-agent-edit, +.cw-agent-row > .cw-agent-edit[aria-expanded="true"] { opacity: 1; } +.cw-assistant-panel .cw-agent-edit:hover { color: var(--chat-text); text-decoration: underline; } +@media (hover: none) { .cw-assistant-panel .cw-agent-edit { opacity: 1; } } .cw-agent-default { border-top: 1px solid var(--chat-border); margin-top: 8px; padding-top: 8px; } .cw-agent-menu p { margin: 12px 8px; color: var(--chat-muted); font-size: 12px; } .cw-assistant-panel :is(.cw-agent-configure, .cw-agent-back) { padding: 8px; color: var(--chat-muted); font-size: 11px; } @@ -24,7 +29,11 @@ .cw-agent-edit-actions { display: flex; justify-content: space-between; padding: 8px; gap: 8px; } .cw-assistant-panel .cw-agent-edit-actions button { padding: 7px 10px; border: 1px solid var(--chat-border); border-radius: 7px; color: var(--chat-text); font-size: 11px; } -.cw-agent-command { display: block; margin: 8px; padding: 10px; background: var(--chat-composer); border: 1px solid var(--chat-border); border-radius: 7px; font-size: 12px; user-select: text; } +.cw-agent-command { display: flex; align-items: center; gap: 8px; margin: 8px; padding: 10px; background: var(--chat-composer); border: 1px solid var(--chat-border); border-radius: 7px; font-size: 12px; user-select: text; } +.cw-agent-command code { flex: 1; min-width: 0; overflow-wrap: anywhere; padding: 0; background: transparent; color: inherit; font-size: inherit; } +.cw-agent-command ::selection { background: #80808060; color: var(--chat-text); } +.cw-assistant-panel .cw-agent-command-copy { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; padding: 4px; border-radius: 4px; color: var(--chat-muted); font-size: 11px; } +.cw-agent-command-copy:hover, .cw-agent-command-copy:focus-visible { background: var(--chat-hover); color: var(--chat-text); } .cw-agent-guide { display: inline-block; margin: 8px; color: var(--chat-text); font-size: 12px; text-decoration: underline; } .cw-agent-submenu { position: fixed; inset: auto; margin: 0; z-index: 100; width: 280px; max-height: min(480px, 65dvh); overflow-y: auto; padding: 10px; border: 1px solid var(--chat-border); border-radius: 14px; background: var(--chat-background); color: var(--chat-text); box-shadow: 0 10px 36px #0005; } diff --git a/src/canvas/assistant-panel.css b/src/canvas/assistant-panel.css index 000982a7..447d1fae 100644 --- a/src/canvas/assistant-panel.css +++ b/src/canvas/assistant-panel.css @@ -161,6 +161,17 @@ border-radius: 7px; color: var(--chat-text); } +.cw-assistant-symbol { + display: grid; + place-items: center; + width: 24px; + height: 24px; + font-family: Arial, sans-serif; + font-weight: 400; + line-height: 24px; +} +.cw-assistant-symbol-plus { font-size: 28px; } +.cw-assistant-symbol-at { font-size: 15px; transform: translateY(-1px); } .cw-assistant-collapse:hover, .cw-assistant-icon-button:hover:not(:disabled) { background: var(--chat-hover); @@ -401,6 +412,10 @@ min-width: 0; } .cw-assistant-panel .chat-picker-trigger { + box-sizing: border-box; + height: 24px; + line-height: 14px; + background: transparent; display: flex; align-items: center; gap: 4px; @@ -742,10 +757,6 @@ } -.cw-assistant-panel .cw-skill-trigger { - background: var(--chat-surface); - padding: 5px 7px; -} .cw-skill-trigger small { color: var(--chat-text); font-size: 10px; diff --git a/src/canvas/model-settings.css b/src/canvas/model-settings.css index 31d5616b..4dc35bc8 100644 --- a/src/canvas/model-settings.css +++ b/src/canvas/model-settings.css @@ -215,16 +215,6 @@ box-sizing: border-box; min-height: var(--settings-control-height); } -.cw-settings-dialog .cw-model-connection select { - appearance: none; - padding-right: 34px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23999999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; -} .cw-settings-dialog .cw-model-connection :is(input:not([type="checkbox"]), select):focus-visible { outline: none; border-color: var(--cw-control-accent); diff --git a/src/canvas/settings.css b/src/canvas/settings.css index 3fa1e464..db6a290c 100644 --- a/src/canvas/settings.css +++ b/src/canvas/settings.css @@ -195,6 +195,16 @@ font-size: var(--settings-control-size); line-height: 20px; } +.cw-settings-dialog :is(.cw-settings-field, .cw-model-connection) select { + appearance: none; + padding-right: 34px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23999999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} .cw-settings-field > select { flex-shrink: 0; width: 144px; diff --git a/src/canvas/useAssistantChat.ts b/src/canvas/useAssistantChat.ts index 9f23398f..dc006f50 100644 --- a/src/canvas/useAssistantChat.ts +++ b/src/canvas/useAssistantChat.ts @@ -7,6 +7,8 @@ import { activateConversation, type ConversationSummary, chatRuntimes, + chatRuntimeStatus, + type AgentRuntime, type RuntimeInfo, streamChat, stopChat, @@ -55,6 +57,7 @@ export function useAssistantChat( const [value, setValue] = useState(empty); const [runtimes, setRuntimes] = useState([]); const [loaded, setLoaded] = useState(false); + const [runtimesLoading, setRuntimesLoading] = useState(true); const [loadAttempt, setLoadAttempt] = useState(0); const [sending, setSending] = useState(false); const [started, setStarted] = useState(false); @@ -106,15 +109,22 @@ export function useAssistantChat( alive.current = true; let cancelled = false; setLoaded(false); - void Promise.all([ - conversation(projectId), - chatRuntimes(projectId), - conversations(projectId), - ]) - .then(([saved, available, list]) => { - setThreads(list.items); + setRuntimes([]); + setRuntimesLoading(true); + setError(""); + void chatRuntimes(projectId, (available) => { + if (!cancelled) setRuntimes(available); + }) + .catch((reason) => { + if (!cancelled) setError(`读取引擎失败:${errorMessage(reason)}`); + }) + .finally(() => { + if (!cancelled) setRuntimesLoading(false); + }); + void Promise.all([conversation(projectId), conversations(projectId)]) + .then(([saved, list]) => { if (cancelled) return; - setRuntimes(available); + setThreads(list.items); const restored = restoreAgentSelection(projectId, saved); setValue({ ...restored, @@ -125,11 +135,6 @@ export function useAssistantChat( ), }); setLoaded(true); - setError( - restored.runtime && !available.some((item) => item.id === restored.runtime) - ? "原 Agent 引擎已移除,请重新选择模型后继续。" - : "", - ); }) .catch((reason) => { if (!cancelled) setError(`读取对话失败:${errorMessage(reason)}`); @@ -142,6 +147,13 @@ export function useAssistantChat( }; }, [projectId, loadAttempt]); + useEffect(() => { + if (loaded && !runtimesLoading && runtimes.length && value.runtime && + !runtimes.some((item) => item.id === value.runtime)) { + setError("原 Agent 引擎已移除,请重新选择模型后继续。"); + } + }, [loaded, runtimesLoading, runtimes, value.runtime]); + useLayoutEffect(() => { if (loaded) rememberChat(projectId, value); }, [projectId, loaded, value]); @@ -203,8 +215,8 @@ export function useAssistantChat( (item) => item.id === (value.runtime ?? "default"), ); const canSend = - !!currentRuntime?.available && - (currentRuntime.id !== "jiuwenswarm" || currentRuntime.status === "ready") && + currentRuntime?.status === "ready" && + !!currentRuntime.available && (currentRuntime.modelSource === "cli" || !!agent); const addAssets = (assets: ChatAsset[]) => { assets.forEach((asset) => @@ -467,8 +479,22 @@ export function useAssistantChat( return { value, runtimes, - refreshRuntimes: async () => { - setRuntimes(await chatRuntimes(projectId)); + runtimesLoading, + refreshRuntimes: async (runtime?: AgentRuntime) => { + if (runtime) { + setRuntimes((items) => items.map((item) => item.id === runtime ? {...item, status: "checking"} : item)); + try { + const status = await chatRuntimeStatus(projectId, runtime); + if (alive.current) setRuntimes((items) => items.map((item) => item.id === runtime ? status : item)); + } catch (reason) { + if (alive.current) setRuntimes((items) => items.map((item) => item.id === runtime ? {...item, status: "unknown"} : item)); + throw reason; + } + return; + } + await chatRuntimes(projectId, (available) => { + if (alive.current) setRuntimes(available); + }); }, setValue, selectAgent: (patch: Partial) => { diff --git a/tests/chatRuntimes.test.ts b/tests/chatRuntimes.test.ts new file mode 100644 index 00000000..eb9b2e93 --- /dev/null +++ b/tests/chatRuntimes.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {chatRuntimes, type RuntimeInfo} from "../src/backend/chat.ts"; + +test("a slow Jiuwen probe does not hold back other harnesses", async (t) => { + let release!: (response: Response) => void; + let ready!: () => void; + const slow = new Promise((resolve) => {release = resolve;}); + const codexReady = new Promise((resolve) => {ready = resolve;}); + const row = (id: RuntimeInfo["id"], status: RuntimeInfo["status"]): RuntimeInfo => ({ + id, label: id, status, available: true, group: "harness", modelSource: "cli", modelOverride: true, + }); + const initial = [row("opencode", "ready"), row("jiuwenswarm", "checking"), row("codex", "checking"), row("claude", "checking")]; + t.mock.method(globalThis, "fetch", async (url: string) => { + if (url.endsWith("?probe=false")) return Response.json(initial); + if (url.endsWith("/jiuwenswarm/status")) return slow; + if (url.endsWith("/claude/status")) throw new Error("unavailable"); + assert.ok(url.endsWith("/codex/status")); + return Response.json(row("codex", "ready")); + }); + let latest: RuntimeInfo[] = []; + let finished = false; + const pending = chatRuntimes("project", (items) => { + latest = items; + if (items.some((item) => item.id === "codex" && item.status === "ready")) ready(); + }).then(() => {finished = true;}); + await codexReady; + assert.equal(finished, false); + assert.equal(latest.find((item) => item.id === "opencode")?.status, "ready"); + assert.equal(latest.find((item) => item.id === "jiuwenswarm")?.status, "checking"); + assert.equal(latest.find((item) => item.id === "codex")?.status, "ready"); + release(Response.json(row("jiuwenswarm", "ready"))); + await pending; + assert.equal(latest.find((item) => item.id === "claude")?.status, "unknown"); +}); diff --git a/tests/test_runtime_readiness.py b/tests/test_runtime_readiness.py index d41f0faf..d12b0912 100644 --- a/tests/test_runtime_readiness.py +++ b/tests/test_runtime_readiness.py @@ -35,8 +35,8 @@ async def probe(engine): monkeypatch.setattr(runtime, 'runtime_readiness', probe) result = asyncio.run(runtime.configured_runtimes()) - assert calls == ['default', 'opencode', 'codex', 'claude', 'jiuwenswarm'] - assert [item['status'] for item in result] == ['ready', 'ready', 'ready', 'needs_login', 'ready'] + assert calls == ['default', 'opencode', 'jiuwenswarm', 'codex', 'claude'] + assert [item['status'] for item in result] == ['ready', 'ready', 'ready', 'ready', 'needs_login'] def test_opencode_binary_sources(monkeypatch, tmp_path): @@ -70,3 +70,30 @@ def test_opencode_cli_and_default_use_separate_model_paths(tmp_path, monkeypatch assert config['provider']['opendirector']['options']['baseURL'] == 'http://loopback' assert runtime.runtime_spec('opencode').modelSource == 'cli' assert runtime.runtime_spec('default').modelSource == 'settings' + + +def test_runtime_catalog_can_skip_probes_and_check_one_harness(tmp_path, monkeypatch): + from uuid import uuid4 + from fastapi.testclient import TestClient + from opendirector_api.main import create_app + from opendirector.infrastructure.local_projects import LocalProjects + calls = [] + async def probe(engine): + calls.append(engine) + return 'ready' + monkeypatch.setattr('opendirector_api.agent.runtime_readiness', probe) + monkeypatch.setattr(runtime.native, 'executable', lambda _: '/installed/cli') + pid = str(uuid4()) + path = f'/api/creation/projects/{pid}' + with TestClient(create_app(LocalProjects(tmp_path)), headers={ + 'X-OpenDirector-Request':'1','X-OpenDirector-Session':str(uuid4())}) as client: + assert client.post(path+'/commands', json={'type':'project.create','projectId':pid, + 'commandId':str(uuid4()),'payload':{'name':'Probes'}}).status_code == 200 + rows = client.get(path+'/assistant/runtimes?probe=false').json() + assert not calls + assert next(row for row in rows if row['id']=='opencode')['status']=='ready' + assert next(row for row in rows if row['id']=='jiuwenswarm')['status']=='checking' + response = client.get(path+'/assistant/runtimes/codex/status') + assert response.status_code == 200 + assert response.json()['status']=='ready' + assert calls == ['codex']