From 297019c16c3c66f88479ad4141338cc2dda66d64 Mon Sep 17 00:00:00 2001 From: The one -Niu <191000457+NIU-123370@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:38:06 +0800 Subject: [PATCH 01/11] fix(chat): complete ACP initialization before session creation Signed-off-by: The one -Niu <191000457+NIU-123370@users.noreply.github.com> --- examples/loopx-chat-acp-adapter-smoke.py | 6 ++++++ loopx/chat_acp.py | 13 ++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/examples/loopx-chat-acp-adapter-smoke.py b/examples/loopx-chat-acp-adapter-smoke.py index 9997b90dae..a9163f08b1 100644 --- a/examples/loopx-chat-acp-adapter-smoke.py +++ b/examples/loopx-chat-acp-adapter-smoke.py @@ -29,6 +29,7 @@ session_id = "acp:fixture/session" active_prompt_id = None session_cwd = "" +client_initialized = False for line in sys.stdin: request = json.loads(line) assert request.get("jsonrpc") == "2.0", request @@ -44,7 +45,12 @@ "sessionCapabilities": {"close": {}}, }, } + elif method == "initialized": + assert request_id is None + client_initialized = True + continue elif method == "session/new": + assert client_initialized, "client must send initialized before session/new" assert request["params"]["mcpServers"] == [] session_cwd = request["params"]["cwd"] result = {"sessionId": session_id} diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index 0d99583465..92278bf9eb 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -29,6 +29,7 @@ EventSink = Callable[[str, dict[str, Any]], None] ACP_PROTOCOL_VERSION = 1 +ACP_SESSION_STARTUP_TIMEOUT_FLOOR_SEC = 45.0 def _reader(stream: Any, messages: "queue.Queue[dict[str, Any] | Exception]") -> None: @@ -151,6 +152,12 @@ def start( raise adapter._runtime_error("ACP Agent selected an unsupported protocol version.") capabilities = initialized.get("agentCapabilities") adapter.agent_capabilities = capabilities if isinstance(capabilities, dict) else {} + # ACP agents may defer session creation until the client confirms + # initialization before accepting ``session/new``. + adapter._notify("initialized", {}) + session_startup_timeout_sec = max( + startup_timeout_sec, ACP_SESSION_STARTUP_TIMEOUT_FLOOR_SEC + ) session_params = {"cwd": str(adapter.agent_work_dir), "mcpServers": []} if resume_thread_id: session_capabilities = adapter.agent_capabilities.get("sessionCapabilities") @@ -160,14 +167,14 @@ def start( "session/resume", {**session_params, "sessionId": resume_thread_id}, request_id=2, - timeout_sec=startup_timeout_sec, + timeout_sec=session_startup_timeout_sec, ) elif bool(adapter.agent_capabilities.get("loadSession")): result = adapter._request( "session/load", {**session_params, "sessionId": resume_thread_id}, request_id=2, - timeout_sec=startup_timeout_sec, + timeout_sec=session_startup_timeout_sec, ) else: raise adapter._runtime_error("ACP Agent does not support session recovery.") @@ -180,7 +187,7 @@ def start( "session/new", session_params, request_id=2, - timeout_sec=startup_timeout_sec, + timeout_sec=session_startup_timeout_sec, ) adapter.session_id = str(result.get("sessionId") or "") if not adapter.session_id: From df015699a86daa676ad7805f54c8cc618a16b276 Mon Sep 17 00:00:00 2001 From: The one -Niu <191000457+NIU-123370@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:54:32 +0800 Subject: [PATCH 02/11] feat(chat): discover ACP Agent models before sessions Signed-off-by: The one -Niu <191000457+NIU-123370@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 20 +++++- .../personal-workspace/channel-header.tsx | 46 +++++++++++-- .../personal-workspace-model.ts | 1 + .../personal-workspace-page.tsx | 4 ++ .../dashboard/src/views/dashboard-page.tsx | 17 ++++- examples/loopx-chat-acp-adapter-smoke.py | 16 ++++- loopx/chat_acp.py | 64 +++++++++++++++++++ loopx/chat_manager.py | 2 + loopx/chat_runtime.py | 35 ++++++++++ loopx/chat_server.py | 29 ++++++++- loopx/chat_session_open.py | 4 ++ loopx/chat_store.py | 7 ++ 12 files changed, 232 insertions(+), 13 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a2e8722828..7be70d9e10 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -703,6 +703,7 @@ export async function createChatSession( agentId?: string, mode: "resume_latest" | "new" = "resume_latest", signal?: AbortSignal, + agentModelId?: string, ) { return requestJson<{ agent_id: string; @@ -718,8 +719,23 @@ export async function createChatSession( // silently re-point the steward channel away from its configured executor. // A project Session carries only the host-issued reference, never a Goal. body: JSON.stringify(context.kind === "project" - ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, mode } - : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, mode, context_kind: context.kind }), + ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, agent_model_id: agentModelId, mode } + : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, agent_model_id: agentModelId, mode, context_kind: context.kind }), + }); +} + +export type AgentModelOption = { value: string; label: string }; +export type AgentModelDiscovery = { + ok: true; + schema_version: "loopx_chat_agent_model_options_v1"; + agent_id: string; + supported: boolean; + model: { id: "model"; label: string; selected: string; options: AgentModelOption[] } | null; +}; + +export function discoverAgentModelOptions(agentId: string, signal?: AbortSignal) { + return requestJson("/api/chat/agent-model-options", { + method: "POST", signal, body: JSON.stringify({agent_id: agentId}), }); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 1e50839992..0d8511e888 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -5,6 +5,8 @@ import { useWorkspaceI18n } from "./i18n"; import type { ManagerChannelBinding, ManagerRuntimeSessionReadback } from "../../data/chat"; import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model"; import { WorkspaceSelect } from "./workspace-select"; +import { discoverAgentModelOptions, type AgentModelDiscovery } from "../../data/chat"; +import { useEffect, useState } from "react"; export function ChannelHeader({ agents, @@ -26,11 +28,13 @@ export function ChannelHeader({ onOpenNavigation, onSelectGoalTab, onSelectAgent, + onSelectAgentModel, onReturnManagerHome, refreshState, readOnlySourceLabel, resumeReviewed, selectedAgentId, + selectedAgentModelId, selectedGoal, selectedGoalTab, workspaceGrantLabel, @@ -58,16 +62,47 @@ export function ChannelHeader({ onOpenNavigation?: () => void; onSelectGoalTab: (tab: WorkspaceGoalTab) => void; onSelectAgent: (agentId: string) => void; + onSelectAgentModel?: (modelId: string) => void; onReturnManagerHome?: () => void; refreshState?: "idle" | "loading" | "done" | "error"; readOnlySourceLabel?: string; /** Resume opens the owner's reviewed preview instead of a local confirmation. */ resumeReviewed?: boolean; selectedAgentId: string; + selectedAgentModelId?: string; selectedGoal: WorkspaceGoal | null; selectedGoalTab: WorkspaceGoalTab; }) { const { locale, t } = useWorkspaceI18n(); + const [modelDiscovery, setModelDiscovery] = useState(null); + const [modelDiscoveryState, setModelDiscoveryState] = useState<"idle" | "loading" | "error">("idle"); + useEffect(() => { + if (readOnlySourceLabel) return; + const controller = new AbortController(); + setModelDiscovery(null); + setModelDiscoveryState("loading"); + void discoverAgentModelOptions(selectedAgentId, controller.signal) + .then((result) => { if (!controller.signal.aborted) { setModelDiscovery(result); setModelDiscoveryState("idle"); } }) + .catch(() => { if (!controller.signal.aborted) setModelDiscoveryState("error"); }); + return () => controller.abort(); + }, [readOnlySourceLabel, selectedAgentId]); + const model = modelDiscovery?.model; + const selectedModel = selectedAgentModelId || model?.selected || model?.options[0]?.value || ""; + const modelControl = model && selectedModel && model.options.length > 1 ? ( + onSelectAgentModel?.(value)} + options={model.options.map((option) => ({ label: option.label, value: option.value }))} + prefixLabel={locale === "zh-CN" ? "模型" : "Model"} + value={selectedModel} + /> + ) : model && selectedModel ? ( + + {locale === "zh-CN" ? "模型" : "Model"}{model.options.find((option) => option.value === selectedModel)?.label ?? selectedModel} + + ) : modelDiscoveryState === "loading" ? {locale === "zh-CN" ? "正在读取模型…" : "Reading models…"} + : modelDiscoveryState === "error" ? {locale === "zh-CN" ? "无法读取模型" : "Models unavailable"} : null; // The chip reports the selected executor, whose credential pays for it, and // the resolved model, so an executor and a model that disagree are visible // instead of arriving as one silent configuration. @@ -138,9 +173,9 @@ export function ChannelHeader({ : null; const runtimeControl = readOnlySourceLabel ? ( - {readOnlySourceLabel}{t("common.readOnly")} - ) : ( - {readOnlySourceLabel}{t("common.readOnly")} + ) : <> + } @@ -152,8 +187,9 @@ export function ChannelHeader({ }))} prefixLabel={t("header.chatRuntime")} value={selectedAgentId} - /> - ); + /> + {modelControl} + ; const executionChipClass = managerExecutionUnavailable ? "personal-execution-chip is-unavailable" : "personal-execution-chip"; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 2b433edd93..dfdd930c31 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -554,6 +554,7 @@ export type PersonalWorkspaceCallbacks = { onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string, settings?: LoopXModeSettings) => void; onSelectAgent?: (agentId: string) => void; + onSelectAgentModel?: (agentId: string, modelId: string) => void; onSelectChannel?: (channel: WorkspaceChannel) => void; onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void; onSelectTodo?: (todoId: string | null) => void; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 85b20d8543..addd26093f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -812,6 +812,7 @@ export function PersonalWorkspacePage({ readOnly = false, typedActionsRevision = 0, selectedAgentId: controlledAgentId, + selectedAgentModelId, selectedGoalId: controlledGoalId, statusSourceControl, serviceNotice, @@ -839,6 +840,7 @@ export function PersonalWorkspacePage({ // re-reads the store instead of waiting for the next mount. typedActionsRevision?: number; selectedAgentId?: string; + selectedAgentModelId?: string; selectedGoalId?: string | null; statusSourceControl?: StatusSourceControl; serviceNotice?: ReactNode; @@ -2140,6 +2142,7 @@ export function PersonalWorkspacePage({ else setSelectedGoalTab(tab); }} onSelectAgent={selectAgent} + onSelectAgentModel={(modelId) => callbacks.onSelectAgentModel?.(selectedAgentId, modelId)} onReturnManagerHome={() => { if (selectedWorkspaceRef) workspaceConversations?.onSelect(null); setSelectedGoalTab("overview"); @@ -2147,6 +2150,7 @@ export function PersonalWorkspacePage({ window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 })); }} selectedAgentId={selectedAgentId} + selectedAgentModelId={selectedAgentModelId} refreshState={refreshState} readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined} selectedGoal={selectedGoal} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 3fe38f9217..aaafe2befc 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1406,6 +1406,7 @@ function PersonalGoalHome({ const agentDefaultForContext = (targetContextId: string) => targetContextId === "manager" ? stewardExecutorAgentId ?? defaultAgentId : defaultAgentId; const [selectedAgents, setSelectedAgents] = useState>(readPersonalAgentSelections); + const [selectedAgentModels, setSelectedAgentModels] = useState>({}); const selectedAgentId = selectedAgents[contextId] ?? agentDefaultForContext(contextId); const selectedAgent = selectAvailableChatAgent(agentOptions, selectedAgentId, defaultAgentId); const [agentMenuOpen, setAgentMenuOpen] = useState(false); @@ -1726,7 +1727,7 @@ function PersonalGoalHome({ // actually made for this context is sent. const sessionEndpoint = targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedAgent.agentId; - const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest"); + const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest", undefined, selectedAgentModels[sessionEndpoint ?? selectedAgent.agentId]); if (cancelled) return; if (targetContext.kind === "manager" && created.session.manager_runtime) { setManagerRuntime(created.session.manager_runtime); @@ -2216,7 +2217,7 @@ function PersonalGoalHome({ const key = `${goalId}:${agentId}`; const existing = sessionIds.current.get(key); if (existing) return existing; - const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal); + const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal, selectedAgentModels[agentId]); recordSessionAdmission(session.session); sessionIds.current.set(key, session.session_id); newSessionRequired.current.delete(key); @@ -2310,7 +2311,7 @@ function PersonalGoalHome({ const mode = newSessionRequired.current.has(sessionKey) ? "new" : "resume_latest"; const sessionEndpoint = targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedRoute.agentId; - const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal); + const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal, selectedAgentModels[sessionEndpoint ?? selectedRoute.agentId]); if (targetContextId === "manager" && session.session.manager_runtime) { setManagerRuntime(session.session.manager_runtime); } @@ -2633,6 +2634,14 @@ function PersonalGoalHome({ setAgentMenuOpen(false); } + function chooseAgentModel(agentId: string, modelId: string) { + setSelectedAgentModels((current) => current[agentId] === modelId ? current : { ...current, [agentId]: modelId }); + const key = `${contextId}:${agentId}`; + sessionIds.current.delete(key); + newSessionRequired.current.add(key); + recordRuntimeBinding(contextId, null); + } + function openGoalChat(goalId: string) { onSelectGoal(goalId, "chat"); setMobilePanel("chat"); @@ -3094,6 +3103,7 @@ function PersonalGoalHome({ }, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, + onSelectAgentModel: chooseAgentModel, onSelectGoal: (goalId, view) => { onSelectGoal(goalId ?? "", view); setMobilePanel("chat"); }, onSelectView, onSelectTodo, @@ -3118,6 +3128,7 @@ function PersonalGoalHome({ } : undefined} readOnly={readOnly} selectedAgentId={selectedAgent.agentId} + selectedAgentModelId={selectedAgentModels[selectedAgent.agentId]} selectedGoalId={selectedGoal?.goalId ?? null} serviceNotice={executionDiscoveryError ?

{t(executionDiscoveryError === "partial" ? "runs.discoveryPartial" : "runs.discoveryOffline")}

diff --git a/examples/loopx-chat-acp-adapter-smoke.py b/examples/loopx-chat-acp-adapter-smoke.py index a9163f08b1..0eb7e1d44a 100644 --- a/examples/loopx-chat-acp-adapter-smoke.py +++ b/examples/loopx-chat-acp-adapter-smoke.py @@ -53,7 +53,10 @@ assert client_initialized, "client must send initialized before session/new" assert request["params"]["mcpServers"] == [] session_cwd = request["params"]["cwd"] - result = {"sessionId": session_id} + result = {"sessionId": session_id, "configOptions": [{ + "id": "model", "name": "Model", "currentValue": "fixture-default", + "options": [{"value": "fixture-default", "name": "Fixture default"}, {"value": "fixture-fast", "name": "Fixture fast"}], + }]} elif method == "session/load": assert request["params"]["sessionId"] == session_id session_cwd = request["params"]["cwd"] @@ -132,6 +135,9 @@ }}, }, ensure_ascii=False), flush=True) result = {"stopReason": "end_turn"} + elif method == "session/set_config_option": + assert request["params"] == {"sessionId": session_id, "configId": "model", "type": "id", "value": "fixture-fast"} + result = {} elif method == "session/cancel": if active_prompt_id is not None: print(json.dumps({"jsonrpc": "2.0", "id": active_prompt_id, "result": {"stopReason": "cancelled"}}), flush=True) @@ -152,9 +158,13 @@ def main() -> None: fake = root / "fake-acp" fake.write_text(FAKE_ACP, encoding="utf-8") fake.chmod(fake.stat().st_mode | stat.S_IXUSR) - adapter = ACPStdioAdapter.start(command=(str(fake),), work_dir=root) + adapter = ACPStdioAdapter.start(command=(str(fake),), work_dir=root, config_values={"model": "fixture-fast"}) assert adapter.upstream_thread_id == "acp:fixture/session" assert adapter.capabilities()["resume"] is True + assert adapter.model_options() == { + "id": "model", "label": "Model", "selected": "fixture-fast", + "options": [{"value": "fixture-default", "label": "Fixture default"}, {"value": "fixture-fast", "label": "Fixture fast"}], + } events: list[tuple[str, dict[str, object]]] = [] response = adapter.start_turn("检查状态", lambda kind, payload: events.append((kind, payload))) assert response["message"] == "ACP 回答。", response @@ -221,9 +231,11 @@ def main() -> None: objective="Exercise the ACP route.", mode="resume_latest", channel_id="task.fixture-acp", + agent_model_id="fixture-fast", ) assert was_resumed is False assert session["upstream_thread_id"] == "acp:fixture/session" + assert session["agent_model_id"] == "fixture-fast" turn, created = first.submit_turn( session_id=str(session["session_id"]), client_turn_id="fixture-turn", diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index 92278bf9eb..f0def15aff 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -60,6 +60,7 @@ class ACPStdioAdapter: work_dir: Path agent_work_dir: Path agent_capabilities: dict[str, Any] + session_config_options: dict[str, dict[str, Any]] = field(default_factory=dict) execution_mode: bool = False startup_timeout_sec: float = 30.0 idle_timeout_sec: float = 180.0 @@ -89,6 +90,7 @@ def start( idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, execution_mode: bool = False, + config_values: dict[str, str] | None = None, ) -> "ACPStdioAdapter": if not command: raise ValueError("ACP command is required") @@ -192,6 +194,12 @@ def start( adapter.session_id = str(result.get("sessionId") or "") if not adapter.session_id: raise adapter._runtime_error("ACP Agent did not return a session id.") + adapter.session_config_options = adapter._session_config_options(result) + # ACP session recovery restores the upstream configuration. Some + # providers omit configOptions from session/load, so reapplying the + # stored pick here would turn a valid recovery into a false error. + if config_values and not resume_thread_id: + adapter.set_config_values(config_values) return adapter except Exception: adapter._terminate() @@ -228,6 +236,62 @@ def capabilities(self) -> dict[str, Any]: "tool_calls": True, } + @staticmethod + def _session_config_options(result: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Normalize ACP's optional session configuration readback. + + Values are retained only in the live adapter. The dashboard receives a + deliberately small projection, so provider-specific response fields + and any local launch details cannot leak through the discovery API. + """ + raw = result.get("configOptions") + if not isinstance(raw, list): + return {} + options: dict[str, dict[str, Any]] = {} + for item in raw: + if not isinstance(item, dict): + continue + config_id = str(item.get("id") or "").strip() + if not config_id or len(config_id) > 80: + continue + choices: list[dict[str, str]] = [] + for choice in item.get("options") if isinstance(item.get("options"), list) else []: + if not isinstance(choice, dict): + continue + value = str(choice.get("value") or choice.get("id") or "").strip() + label = _compact_label(choice.get("name") or choice.get("label") or value, fallback=value) + if value and len(value) <= 160: + choices.append({"value": value, "label": label}) + selected = str(item.get("currentValue") or item.get("value") or "").strip() + options[config_id] = { + "label": _compact_label(item.get("name") or item.get("label") or config_id, fallback=config_id), + "selected": selected if len(selected) <= 160 else "", + "options": choices, + } + return options + + def model_options(self) -> dict[str, Any] | None: + option = self.session_config_options.get("model") + if not option: + return None + return {"id": "model", **option} + + def set_config_values(self, values: dict[str, str]) -> None: + """Apply caller-selected ACP options after the session exists.""" + for config_id, value in values.items(): + known = self.session_config_options.get(config_id) + if known is None: + raise self._runtime_error("ACP Agent does not offer the selected configuration.", error_code="unsupported_configuration") + allowed = {str(item["value"]) for item in known["options"]} + if value not in allowed: + raise self._runtime_error("ACP Agent rejected an unavailable configuration value.", error_code="unsupported_configuration") + self._request( + "session/set_config_option", + {"sessionId": self.session_id, "configId": config_id, "type": "id", "value": value}, + timeout_sec=self.startup_timeout_sec, + ) + known["selected"] = value + def _write(self, payload: dict[str, Any]) -> None: if self.process.stdin is None: raise self._runtime_error("ACP Agent input stream is closed.") diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index 2f7b0ac0d6..65eefba288 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -888,6 +888,7 @@ def open_manager_session( mode: str = "resume_latest", provider: str = "", audience: str = "", + agent_model_id: str | None = None, ) -> tuple[dict[str, Any], bool]: """Open the steward channel's Session through its own endpoint owner. @@ -926,6 +927,7 @@ def open_manager_session( channel_id=channel_id, agent_goal_id=MANAGER_AGENT_GOAL_ID, manager_executor_allocation=allocation, + agent_model_id=agent_model_id, ) diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 157d1e089d..065d878b92 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -390,6 +390,29 @@ def capabilities(self) -> list[dict[str, Any]]: ) return [*builtins, *(endpoint.public_summary() for endpoint in self.endpoint_registry.list())] + def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[str, Any]: + """Read model choices from one selected, available ACP endpoint. + + This is intentionally not a PATH scan: the controller launches only an + endpoint already present in the built-in catalog or owner-local registry. + The temporary upstream session is closed before returning. + """ + capability = next((row for row in self.capabilities() if row["agent_id"] == agent_id), None) + if capability is None or not capability.get("available"): + raise agent_endpoint_error(agent_id, reason=str((capability or {}).get("unavailable_reason") or "")) + if str(capability.get("adapter_kind")) != "acp": + return {"agent_id": agent_id, "model": None, "supported": False} + adapter = self._start_adapter( + agent_id=agent_id, + work_dir=work_dir, + goal_id=None, + objective="", + ) + try: + return {"agent_id": agent_id, "model": adapter.model_options(), "supported": True} + finally: + adapter.close_session() + @staticmethod def _managed_upstream_mode(session: dict[str, Any]) -> str: if session.get("agent_id") == "codex": @@ -437,6 +460,7 @@ def _start_adapter( loopx_tools: bool = False, executor_model: Mapping[str, str | None] | None = None, project_context: dict[str, str] | None = None, + agent_config: dict[str, str] | None = None, ) -> ChatRuntimeAdapter: if project_context is not None: project_context_policy.validate_project_native_executor(agent_id, project_context) @@ -565,6 +589,7 @@ def _start_adapter( idle_timeout_sec=self.idle_timeout_sec, hard_timeout_sec=self.hard_timeout_sec, execution_mode=execution_mode, + config_values=agent_config, ) endpoint = self.endpoint_registry.get(agent_id) if endpoint is not None: @@ -577,6 +602,7 @@ def _start_adapter( idle_timeout_sec=self.idle_timeout_sec, hard_timeout_sec=self.hard_timeout_sec, execution_mode=execution_mode, + config_values=agent_config, ) raise ValueError(f"unknown Agent endpoint: {agent_id}") @@ -595,6 +621,7 @@ def open_session( project_ref: str | None = None, conversation_binding_id: str | None = None, source_context: dict[str, Any] | None = None, + agent_model_id: str | None = None, ) -> tuple[dict[str, Any], bool]: capability = next((item for item in self.capabilities() if item["agent_id"] == agent_id), None) if mode not in {"resume_latest", "new"}: @@ -680,6 +707,11 @@ def open_session( latest = None if latest is not None and goal_instance_id is not None and latest.get("goal_instance_id") != goal_instance_id: latest = None + if latest is not None and agent_model_id and latest.get("agent_model_id") != agent_model_id: + # A model is a session binding for ACP. Never silently + # retarget an existing conversation when the user chose a + # different one; a new session makes that boundary visible. + latest = None if latest is not None and latest.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: return latest, True if capability is None: @@ -702,6 +734,7 @@ def open_session( manager_runtime=manager_runtime, project_context=project_context, executor_model=alloc.manager_executor_model(manager_executor_allocation), + agent_config={"model": agent_model_id} if agent_model_id else None, ) persisted = self.store.create_session( goal_id=goal_id, @@ -715,6 +748,7 @@ def open_session( codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, project_context=project_context, steward_context=steward_context, + agent_model_id=agent_model_id, ) persisted = self.project_contexts.initialize_bound_scope(self.store, persisted, runtime_root=self.coordination_runtime_root) if is_manager_channel(selected_channel): @@ -945,6 +979,7 @@ def _ensure_adapter_locked( if model_allocation is not None else alloc.restored_executor_model(session)), manager_runtime=manager_runtime, project_context=session.get("project_context"), + agent_config={"model": str(session["agent_model_id"])} if session.get("agent_model_id") else None, ) if session.get("upstream_mode") == CODEX_GOAL_CHAT_MODE: try: diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 9bfeb40d8d..7617114e22 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -116,6 +116,7 @@ DEFAULT_CHAT_STATUS_PATH = "/status.json" CHAT_CAPABILITIES_PATH = "/api/chat/capabilities" CHAT_ENDPOINTS_PATH = "/api/chat/endpoints" +CHAT_AGENT_MODEL_OPTIONS_PATH = "/api/chat/agent-model-options" CHAT_SESSIONS_PATH = "/api/chat/sessions" CHAT_ATTACH_SESSION_PATH = f"{CHAT_SESSIONS_PATH}/attach" CHAT_PROJECTION_MESSAGES_PATH = "/api/chat/projection-messages" @@ -582,7 +583,7 @@ def _serve_asset(self, path: str) -> None: def _create_session(self) -> None: try: body = self._read_json() - unknown = set(body) - {"goal_id", "agent_id", "mode", "context_kind", "project_ref"} + unknown = set(body) - {"goal_id", "agent_id", "agent_model_id", "mode", "context_kind", "project_ref"} if unknown: raise ValueError("unknown session field") goal_id = _compact_text(body.get("goal_id"), limit=160) or self.server.selected_goal_id or "" @@ -591,6 +592,7 @@ def _create_session(self) -> None: # own default through its own owner instead of inheriting whatever # executor this client happens to ship with. requested_endpoint = _compact_text(body.get("agent_id"), limit=80) + requested_model = _compact_text(body.get("agent_model_id"), limit=160) mode = _compact_text(body.get("mode"), limit=40) or "resume_latest" context_kind = _compact_text(body.get("context_kind"), limit=40) or "goal" if context_kind not in {"goal", "manager", "project"}: @@ -630,6 +632,7 @@ def _create_session(self) -> None: objective=runtime_objective, mode=mode, requested_endpoint=requested_endpoint, + agent_model_id=requested_model, **({"project_ref": project_ref} if context_kind == "project" else {}), ) except CodexChatAgentError as exc: @@ -646,6 +649,7 @@ def _create_session(self) -> None: "session_id": public["session_id"], "goal_id": public["goal_id"], "agent_id": public.get("executor_endpoint_id") or requested_endpoint, + "agent_model_id": public.get("agent_model_id"), "context_kind": context_kind, "resumed": resumed, "session": public, @@ -653,6 +657,28 @@ def _create_session(self) -> None: status=200 if resumed else 201, ) + def _agent_model_options(self) -> None: + try: + body = self._read_json() + if set(body) != {"agent_id"}: + raise ValueError("agent_id is required") + agent_id = _compact_text(body.get("agent_id"), limit=80) + if not agent_id: + raise ValueError("agent_id is required") + # Discovery is deliberately bounded to a selected, known endpoint. + # It opens no persisted LoopX Chat session and exposes no command or + # local workspace data to the browser. + result = self.server.runtime_controller.inspect_agent_model_options( + agent_id, work_dir=Path.cwd() + ) + except CodexChatAgentError as exc: + self._send_error(str(exc), status=424, gate=exc.gate, error_code=exc.error_code) + return + except Exception as exc: # noqa: BLE001 - compact local validation error. + self._send_error(str(exc)) + return + self._send_json({"ok": True, "schema_version": "loopx_chat_agent_model_options_v1", **result}) + def _record_projection_exchange(self) -> None: """Persist a status-only exchange in the same owner-local message store.""" try: @@ -1485,6 +1511,7 @@ def do_POST(self) -> None: path = urlparse(self.path).path post_dispatch = { CHAT_SESSIONS_PATH: self._create_session, + CHAT_AGENT_MODEL_OPTIONS_PATH: self._agent_model_options, CHAT_ATTACH_SESSION_PATH: self._attach_session, CHAT_PROJECTION_MESSAGES_PATH: self._record_projection_exchange, CHAT_ACTION_PREVIEW_PATH: self._action_preview, diff --git a/loopx/chat_session_open.py b/loopx/chat_session_open.py index 63cc79720f..fb20424cbf 100644 --- a/loopx/chat_session_open.py +++ b/loopx/chat_session_open.py @@ -38,6 +38,7 @@ def open_chat_session( objective: str, mode: str, requested_endpoint: str = "", + agent_model_id: str = "", project_ref: str | None = None, ) -> tuple[dict[str, Any], bool]: """Return ``(session, resumed)`` for one entry-point request.""" @@ -49,6 +50,7 @@ def open_chat_session( work_dir=work_dir, executor_endpoint_id=requested_endpoint or None, mode=mode, + agent_model_id=agent_model_id or None, ) if context_kind == "project": if goal_id is not None or not project_ref: @@ -56,6 +58,7 @@ def open_chat_session( return controller.open_session( goal_id=None, agent_id=requested_endpoint or DEFAULT_GOAL_AGENT_ID, work_dir=work_dir, objective=objective, mode=mode, project_ref=project_ref, + agent_model_id=agent_model_id or None, ) if context_kind != "goal": raise ValueError("unknown conversation context") @@ -67,4 +70,5 @@ def open_chat_session( mode=mode, channel_id=f"goal.{goal_id}", agent_goal_id=goal_id, + agent_model_id=agent_model_id or None, ) diff --git a/loopx/chat_store.py b/loopx/chat_store.py index fbeac0e987..458dd0e4ed 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -257,6 +257,7 @@ def create_session( codex_home: str | None = None, project_context: dict[str, Any] | None = None, steward_context: dict[str, Any] | None = None, + agent_model_id: str | None = None, ) -> dict[str, Any]: now = utc_now() token = _opaque_id(session_id or uuid.uuid4().hex, field="session_id") @@ -304,6 +305,7 @@ def create_session( field="executor_endpoint_id", ) normalized_adapter_kind = _opaque_id(adapter_kind, field="adapter_kind") + normalized_agent_model_id = _opaque_id(agent_model_id, field="agent_model_id") if agent_model_id else None normalized_upstream_thread_id = _upstream_id(upstream_thread_id) normalized_upstream_mode = _opaque_id(upstream_mode, field="upstream_mode") normalized_channel_id = _opaque_id( @@ -330,6 +332,7 @@ def create_session( else {} ), "agent_id": normalized_agent_id, + "agent_model_id": normalized_agent_model_id, "executor_endpoint_id": normalized_executor_endpoint_id, "adapter_kind": normalized_adapter_kind, "upstream_thread_id": normalized_upstream_thread_id, @@ -388,6 +391,7 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: "manager_runtime_standing_grant", "manager_runtime_tool_classes", "manager_executor_allocation", + "agent_model_id", "goal_id", } unknown = set(changes) - allowed @@ -408,6 +412,8 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: raise ValueError("only the owner manager may migrate to global identity") if "upstream_thread_id" in changes: changes["upstream_thread_id"] = _upstream_id(changes["upstream_thread_id"]) + if "agent_model_id" in changes: + changes["agent_model_id"] = _opaque_id(changes["agent_model_id"], field="agent_model_id") if changes["agent_model_id"] else None if "upstream_mode" in changes: changes["upstream_mode"] = _opaque_id(changes["upstream_mode"], field="upstream_mode") if "manager_authorization_scope_id" in changes: @@ -1914,6 +1920,7 @@ def public_session(self, payload: dict[str, Any]) -> dict[str, Any]: key: payload.get(key) for key in ( "session_id", "goal_id", "agent_id", "adapter_kind", "status", + "agent_model_id", "active_turn_id", "last_error_code", "created_at", "updated_at", "last_activity_at", ) } | { From 18aa5667db3bf03a0b85aed1a0a289d8d06a75e7 Mon Sep 17 00:00:00 2001 From: The one -Niu <191000457+NIU-123370@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:55:35 +0800 Subject: [PATCH 03/11] fix(chat): keep single Agent models selectable Signed-off-by: The one -Niu <191000457+NIU-123370@users.noreply.github.com> --- .../src/features/personal-workspace/channel-header.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 0d8511e888..b2dad03f04 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -88,7 +88,7 @@ export function ChannelHeader({ }, [readOnlySourceLabel, selectedAgentId]); const model = modelDiscovery?.model; const selectedModel = selectedAgentModelId || model?.selected || model?.options[0]?.value || ""; - const modelControl = model && selectedModel && model.options.length > 1 ? ( + const modelControl = model && selectedModel ? ( - ) : model && selectedModel ? ( - - {locale === "zh-CN" ? "模型" : "Model"}{model.options.find((option) => option.value === selectedModel)?.label ?? selectedModel} - ) : modelDiscoveryState === "loading" ? {locale === "zh-CN" ? "正在读取模型…" : "Reading models…"} : modelDiscoveryState === "error" ? {locale === "zh-CN" ? "无法读取模型" : "Models unavailable"} : null; // The chip reports the selected executor, whose credential pays for it, and From be7967df77ccdb56706e91568fc484eb508916fd Mon Sep 17 00:00:00 2001 From: The one -Niu <191000457+NIU-123370@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:00:00 +0800 Subject: [PATCH 04/11] feat(chat): expose local agent model catalogs Signed-off-by: The one -Niu <191000457+NIU-123370@users.noreply.github.com> --- loopx/chat_providers.py | 5 +++++ loopx/chat_runtime.py | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/loopx/chat_providers.py b/loopx/chat_providers.py index a0249515d6..5b2ffb10c6 100644 --- a/loopx/chat_providers.py +++ b/loopx/chat_providers.py @@ -120,6 +120,7 @@ class ClaudeCodeAdapter: session_id: str tool_scope: str = "read_only" context_summary: str = "" + model: str | None = None resumed: bool = False current_process: subprocess.Popen[str] | None = field(default=None, repr=False) lock: threading.RLock = field(default_factory=threading.RLock, repr=False) @@ -137,6 +138,7 @@ def start( resume_thread_id: str | None = None, tool_scope: str = "read_only", context_summary: str = "", + model: str | None = None, ) -> "ClaudeCodeAdapter": resolved = shutil.which(claude_bin) if not resolved: @@ -152,6 +154,7 @@ def start( session_id=resume_thread_id or str(uuid.uuid4()), tool_scope=tool_scope, context_summary=context_summary, + model=model, resumed=bool(resume_thread_id), ) @@ -183,6 +186,8 @@ def start_turn(self, message: str, event_sink: EventSink) -> dict[str, Any]: command.extend(["--resume", self.session_id]) else: command.extend(["--session-id", self.session_id]) + if self.model: + command.extend(["--model", self.model]) command.append(_turn_prompt(message, context_summary=self.context_summary)) event_sink("turn.started", {"upstream_turn_id": self.session_id}) event_sink("agent.phase", {"label": "正在连接 Claude Code"}) diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 065d878b92..47f7b73a35 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -9,6 +9,8 @@ import logging import os from pathlib import Path +import shutil +import subprocess import threading import time from typing import Any, Callable, Mapping, Protocol @@ -400,6 +402,21 @@ def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[ capability = next((row for row in self.capabilities() if row["agent_id"] == agent_id), None) if capability is None or not capability.get("available"): raise agent_endpoint_error(agent_id, reason=str((capability or {}).get("unavailable_reason") or "")) + if agent_id == "codex": + return {"agent_id": agent_id, "model": self._codex_model_options(), "supported": True} + if agent_id == "claude-code": + return { + "agent_id": agent_id, + "supported": True, + "model": { + "id": "model", "label": "Model", "selected": "sonnet", + "options": [ + {"value": "sonnet", "label": "Sonnet"}, + {"value": "opus", "label": "Opus"}, + {"value": "fable", "label": "Fable"}, + ], + }, + } if str(capability.get("adapter_kind")) != "acp": return {"agent_id": agent_id, "model": None, "supported": False} adapter = self._start_adapter( @@ -413,6 +430,33 @@ def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[ finally: adapter.close_session() + def _codex_model_options(self) -> dict[str, Any] | None: + """Project the local Codex catalog without exposing its instructions.""" + executable = self.codex_bin if Path(self.codex_bin).is_absolute() else shutil.which(self.codex_bin) + if not executable: + return None + try: + result = subprocess.run( + [str(executable), "debug", "models"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, encoding="utf-8", errors="replace", timeout=15, check=False, + ) + payload = json.loads(result.stdout) if result.returncode == 0 else {} + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + return None + rows = payload.get("models") if isinstance(payload, dict) else None + if not isinstance(rows, list): + return None + options = [ + {"value": slug, "label": " ".join(str(row.get("display_name") or slug).split())[:100]} + for row in rows + if isinstance(row, dict) + and isinstance((slug := str(row.get("slug") or "").strip()), str) + and slug and len(slug) <= 160 + and str(row.get("visibility") or "list") == "list" + ] + return {"id": "model", "label": "Model", "selected": options[0]["value"] if options else "", "options": options} if options else None + @staticmethod def _managed_upstream_mode(session: dict[str, Any]) -> str: if session.get("agent_id") == "codex": @@ -503,6 +547,8 @@ def _start_adapter( if goal_id == MANAGER_AGENT_GOAL_ID and not execution_mode else executor_model or {} ) + if agent_config and agent_config.get("model"): + model_config = {**model_config, "model": agent_config["model"]} return CodexAppServerAdapter.start( codex_bin=self.codex_bin, codex_home=self.codex_home, @@ -544,6 +590,7 @@ def _start_adapter( resume_thread_id=resume_thread_id, tool_scope="read_only", context_summary=f"{goal_id}: {objective}".strip() if goal_id is not None else objective.strip(), + model=agent_config.get("model") if agent_config else None, ) if agent_id == MANAGED_TURN_HOST: # The managed host has no interactive session transport, so this From 093cd3d7b0bc9218d2fef5c3bf9ee3faa7552199 Mon Sep 17 00:00:00 2001 From: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:36:45 +0800 Subject: [PATCH 05/11] fix(chat): preserve selected agent models Signed-off-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 1 + .../dashboard/src/views/dashboard-page.tsx | 33 +++++++++++------ examples/loopx-chat-acp-adapter-smoke.py | 14 ++++---- loopx/chat_runtime.py | 35 +++++++++++-------- loopx/chat_store.py | 18 ++++++++-- tests/test_chat_store_input_validation.py | 10 ++++++ 6 files changed, 78 insertions(+), 33 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 7be70d9e10..dc02e8a7fd 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -752,6 +752,7 @@ export type ChatSessionSummary = { goal_id: string | null; project_ref?: string | null; agent_id: string; + agent_model_id?: string | null; adapter_kind: string; channel_id?: string; status: string; diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index aaafe2befc..89306b709f 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -603,6 +603,10 @@ function readPersonalAgentSelections() { } } +function agentModelSelectionKey(contextId: string, agentId: string) { + return `${contextId}:${agentId}`; +} + type PersonalManagerAnswer = { lines: string[]; text: string; @@ -1585,7 +1589,12 @@ function PersonalGoalHome({ return () => { controller.abort(); clearTimeout(timer); }; }, [readOnly, conversationReturnSessionKey]); - function recordSessionAdmission(session: ChatSessionSummary) { + function recordSessionAdmission(targetContextId: string, session: ChatSessionSummary) { + if (session.agent_model_id) { + const key = agentModelSelectionKey(targetContextId, session.agent_id); + setSelectedAgentModels((current) => current[key] === session.agent_model_id + ? current : { ...current, [key]: session.agent_model_id! }); + } const queues = chatSessionQueuesFollowUps(session); const supportsSteering = chatSessionSupportsSteering(session); setSteeringSessionIds((current) => { @@ -1713,6 +1722,7 @@ function PersonalGoalHome({ if (cancelled) return; const latest = currentChannelSession(history, selectedAgent.agentId); latestDiscoveredSessionId = latest?.session_id ?? null; + if (latest) recordSessionAdmission(targetContextId, latest); if (latest && !latest.resumable) { newSessionRequired.current.add(sessionKey); recordRuntimeBinding(targetContextId, { @@ -1727,12 +1737,13 @@ function PersonalGoalHome({ // actually made for this context is sent. const sessionEndpoint = targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedAgent.agentId; - const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest", undefined, selectedAgentModels[sessionEndpoint ?? selectedAgent.agentId]); + const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest", undefined, + latest?.agent_model_id ?? selectedAgentModels[agentModelSelectionKey(targetContextId, sessionEndpoint ?? selectedAgent.agentId)]); if (cancelled) return; if (targetContext.kind === "manager" && created.session.manager_runtime) { setManagerRuntime(created.session.manager_runtime); } - recordSessionAdmission(created.session); + recordSessionAdmission(targetContextId, created.session); sessionIds.current.set(sessionKey, created.session_id); const activeSnapshot = history.snapshots.find( (snapshot) => snapshot.session.session_id === created.session_id, @@ -2217,8 +2228,8 @@ function PersonalGoalHome({ const key = `${goalId}:${agentId}`; const existing = sessionIds.current.get(key); if (existing) return existing; - const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal, selectedAgentModels[agentId]); - recordSessionAdmission(session.session); + const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal, selectedAgentModels[key]); + recordSessionAdmission(goalId, session.session); sessionIds.current.set(key, session.session_id); newSessionRequired.current.delete(key); recordRuntimeBinding(goalId, {agentId, resumable: true, sessionId: session.session_id, status: session.session.status}); @@ -2311,11 +2322,12 @@ function PersonalGoalHome({ const mode = newSessionRequired.current.has(sessionKey) ? "new" : "resume_latest"; const sessionEndpoint = targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedRoute.agentId; - const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal, selectedAgentModels[sessionEndpoint ?? selectedRoute.agentId]); + const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal, + selectedAgentModels[agentModelSelectionKey(targetContextId, sessionEndpoint ?? selectedRoute.agentId)]); if (targetContextId === "manager" && session.session.manager_runtime) { setManagerRuntime(session.session.manager_runtime); } - recordSessionAdmission(session.session); + recordSessionAdmission(targetContextId, session.session); sessionId = session.session_id; sessionIds.current.set(sessionKey, sessionId); recordRuntimeBinding(targetContextId, { @@ -2586,7 +2598,7 @@ function PersonalGoalHome({ if (!sessionId) return; try { const restored = await resumeChatSession(sessionId); - recordSessionAdmission(restored.session); + recordSessionAdmission(targetContextId, restored.session); sessionIds.current.set(sessionKey, sessionId); newSessionRequired.current.delete(sessionKey); recordRuntimeBinding(targetContextId, { @@ -2635,7 +2647,8 @@ function PersonalGoalHome({ } function chooseAgentModel(agentId: string, modelId: string) { - setSelectedAgentModels((current) => current[agentId] === modelId ? current : { ...current, [agentId]: modelId }); + const selectionKey = agentModelSelectionKey(contextId, agentId); + setSelectedAgentModels((current) => current[selectionKey] === modelId ? current : { ...current, [selectionKey]: modelId }); const key = `${contextId}:${agentId}`; sessionIds.current.delete(key); newSessionRequired.current.add(key); @@ -3128,7 +3141,7 @@ function PersonalGoalHome({ } : undefined} readOnly={readOnly} selectedAgentId={selectedAgent.agentId} - selectedAgentModelId={selectedAgentModels[selectedAgent.agentId]} + selectedAgentModelId={selectedAgentModels[agentModelSelectionKey(contextId, selectedAgent.agentId)]} selectedGoalId={selectedGoal?.goalId ?? null} serviceNotice={executionDiscoveryError ?

{t(executionDiscoveryError === "partial" ? "runs.discoveryPartial" : "runs.discoveryOffline")}

diff --git a/examples/loopx-chat-acp-adapter-smoke.py b/examples/loopx-chat-acp-adapter-smoke.py index 0eb7e1d44a..b210c539c8 100644 --- a/examples/loopx-chat-acp-adapter-smoke.py +++ b/examples/loopx-chat-acp-adapter-smoke.py @@ -55,7 +55,7 @@ session_cwd = request["params"]["cwd"] result = {"sessionId": session_id, "configOptions": [{ "id": "model", "name": "Model", "currentValue": "fixture-default", - "options": [{"value": "fixture-default", "name": "Fixture default"}, {"value": "fixture-fast", "name": "Fixture fast"}], + "options": [{"value": "fixture-default", "name": "Fixture default"}, {"value": "fixture/fast", "name": "Fixture fast"}], }]} elif method == "session/load": assert request["params"]["sessionId"] == session_id @@ -136,7 +136,7 @@ }, ensure_ascii=False), flush=True) result = {"stopReason": "end_turn"} elif method == "session/set_config_option": - assert request["params"] == {"sessionId": session_id, "configId": "model", "type": "id", "value": "fixture-fast"} + assert request["params"] == {"sessionId": session_id, "configId": "model", "type": "id", "value": "fixture/fast"} result = {} elif method == "session/cancel": if active_prompt_id is not None: @@ -158,12 +158,12 @@ def main() -> None: fake = root / "fake-acp" fake.write_text(FAKE_ACP, encoding="utf-8") fake.chmod(fake.stat().st_mode | stat.S_IXUSR) - adapter = ACPStdioAdapter.start(command=(str(fake),), work_dir=root, config_values={"model": "fixture-fast"}) + adapter = ACPStdioAdapter.start(command=(str(fake),), work_dir=root, config_values={"model": "fixture/fast"}) assert adapter.upstream_thread_id == "acp:fixture/session" assert adapter.capabilities()["resume"] is True assert adapter.model_options() == { - "id": "model", "label": "Model", "selected": "fixture-fast", - "options": [{"value": "fixture-default", "label": "Fixture default"}, {"value": "fixture-fast", "label": "Fixture fast"}], + "id": "model", "label": "Model", "selected": "fixture/fast", + "options": [{"value": "fixture-default", "label": "Fixture default"}, {"value": "fixture/fast", "label": "Fixture fast"}], } events: list[tuple[str, dict[str, object]]] = [] response = adapter.start_turn("检查状态", lambda kind, payload: events.append((kind, payload))) @@ -231,11 +231,11 @@ def main() -> None: objective="Exercise the ACP route.", mode="resume_latest", channel_id="task.fixture-acp", - agent_model_id="fixture-fast", + agent_model_id="fixture/fast", ) assert was_resumed is False assert session["upstream_thread_id"] == "acp:fixture/session" - assert session["agent_model_id"] == "fixture-fast" + assert session["agent_model_id"] == "fixture/fast" turn, created = first.submit_turn( session_id=str(session["session_id"]), client_turn_id="fixture-turn", diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 47f7b73a35..bcf62688cd 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -57,6 +57,7 @@ CHAT_SESSION_MODE_ATTACHED, TERMINAL_TURN_STATES, ChatSessionStore, + normalize_agent_model_id, utc_now, ) from .chat_providers import ClaudeCodeAdapter, direct_model_from_environment @@ -670,6 +671,8 @@ def open_session( source_context: dict[str, Any] | None = None, agent_model_id: str | None = None, ) -> tuple[dict[str, Any], bool]: + if agent_model_id: + agent_model_id = normalize_agent_model_id(agent_model_id) capability = next((item for item in self.capabilities() if item["agent_id"] == agent_id), None) if mode not in {"resume_latest", "new"}: raise ValueError("mode must be resume_latest or new") @@ -783,20 +786,24 @@ def open_session( executor_model=alloc.manager_executor_model(manager_executor_allocation), agent_config={"model": agent_model_id} if agent_model_id else None, ) - persisted = self.store.create_session( - goal_id=goal_id, - goal_instance_id=goal_instance_id, - agent_id=agent_id, - executor_endpoint_id=agent_id, - adapter_kind=str(capability["adapter_kind"]), - upstream_thread_id=adapter.upstream_thread_id, - upstream_mode="chat" if agent_id == "codex" else "default", - channel_id=selected_channel, - codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, - project_context=project_context, - steward_context=steward_context, - agent_model_id=agent_model_id, - ) + try: + persisted = self.store.create_session( + goal_id=goal_id, + goal_instance_id=goal_instance_id, + agent_id=agent_id, + executor_endpoint_id=agent_id, + adapter_kind=str(capability["adapter_kind"]), + upstream_thread_id=adapter.upstream_thread_id, + upstream_mode="chat" if agent_id == "codex" else "default", + channel_id=selected_channel, + codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, + project_context=project_context, + steward_context=steward_context, + agent_model_id=agent_model_id, + ) + except Exception: + adapter.close_session() + raise persisted = self.project_contexts.initialize_bound_scope(self.store, persisted, runtime_root=self.coordination_runtime_root) if is_manager_channel(selected_channel): assert manager_runtime is not None diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 458dd0e4ed..65d09ff517 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -48,6 +48,7 @@ SESSION_QUEUE_TTL_SECONDS = 3600 _OPAQUE_ID = re.compile(r"^[A-Za-z0-9._-]{1,160}$") +_AGENT_MODEL_ID_MAX_LENGTH = 160 def utc_now() -> str: @@ -68,6 +69,19 @@ def _opaque_id(value: Any, *, field: str) -> str: return token +def normalize_agent_model_id(value: Any) -> str: + """Keep a provider model value without applying file-name rules. + + ACP option values are protocol strings rather than LoopX entity IDs. They + are persisted in JSON only, so retain a bounded printable value while + rejecting controls that would make logs or projections unsafe. + """ + token = str(value or "").strip() + if not token or len(token) > _AGENT_MODEL_ID_MAX_LENGTH or any(ord(character) < 32 for character in token): + raise ValueError("agent_model_id must be a bounded model value") + return token + + def _upstream_id(value: Any) -> str: token = str(value or "").strip() if not token or len(token) > 1024 or any(ord(character) < 32 for character in token): @@ -305,7 +319,7 @@ def create_session( field="executor_endpoint_id", ) normalized_adapter_kind = _opaque_id(adapter_kind, field="adapter_kind") - normalized_agent_model_id = _opaque_id(agent_model_id, field="agent_model_id") if agent_model_id else None + normalized_agent_model_id = normalize_agent_model_id(agent_model_id) if agent_model_id else None normalized_upstream_thread_id = _upstream_id(upstream_thread_id) normalized_upstream_mode = _opaque_id(upstream_mode, field="upstream_mode") normalized_channel_id = _opaque_id( @@ -413,7 +427,7 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: if "upstream_thread_id" in changes: changes["upstream_thread_id"] = _upstream_id(changes["upstream_thread_id"]) if "agent_model_id" in changes: - changes["agent_model_id"] = _opaque_id(changes["agent_model_id"], field="agent_model_id") if changes["agent_model_id"] else None + changes["agent_model_id"] = normalize_agent_model_id(changes["agent_model_id"]) if changes["agent_model_id"] else None if "upstream_mode" in changes: changes["upstream_mode"] = _opaque_id(changes["upstream_mode"], field="upstream_mode") if "manager_authorization_scope_id" in changes: diff --git a/tests/test_chat_store_input_validation.py b/tests/test_chat_store_input_validation.py index 0f9dd2d5ec..8deb00db31 100644 --- a/tests/test_chat_store_input_validation.py +++ b/tests/test_chat_store_input_validation.py @@ -9,6 +9,16 @@ from loopx.chat_store import CHAT_SESSION_SCHEMA_VERSION, ChatSessionStore +def test_agent_model_value_keeps_provider_protocol_characters() -> None: + assert chat_store.normalize_agent_model_id("vendor/model-fast") == "vendor/model-fast" + + +@pytest.mark.parametrize("value", ["", "model\nnext", "x" * 161]) +def test_agent_model_value_rejects_empty_controls_and_unbounded_values(value: str) -> None: + with pytest.raises(ValueError, match="agent_model_id"): + chat_store.normalize_agent_model_id(value) + + def test_session_id_cannot_escape_sessions_directory(tmp_path: Path) -> None: store = ChatSessionStore(tmp_path) (store.root / "session.json").write_text( From f0078c727233b5977208ebb1b5b9cfbc2df5d4e6 Mon Sep 17 00:00:00 2001 From: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:13:13 +0800 Subject: [PATCH 06/11] fix(chat): reconcile ACP model readback and grouped options Signed-off-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- .../personal-workspace/channel-header.tsx | 14 +++-- .../dashboard/src/views/dashboard-page.tsx | 21 ++++---- loopx/chat_acp.py | 54 +++++++++++++------ loopx/chat_providers.py | 28 ++++++++++ loopx/chat_runtime.py | 40 +++----------- loopx/chat_store.py | 4 +- 6 files changed, 98 insertions(+), 63 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index b2dad03f04..b7e08c9800 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -87,17 +87,23 @@ export function ChannelHeader({ return () => controller.abort(); }, [readOnlySourceLabel, selectedAgentId]); const model = modelDiscovery?.model; - const selectedModel = selectedAgentModelId || model?.selected || model?.options[0]?.value || ""; - const modelControl = model && selectedModel ? ( + const selectedModel = selectedAgentModelId === undefined ? model?.selected || model?.options[0]?.value || "" : selectedAgentModelId; + const modelControl = model && model.options.length ? ( onSelectAgentModel?.(value)} - options={model.options.map((option) => ({ label: option.label, value: option.value }))} + options={[ + ...(!selectedModel ? [{ disabled: true, label: locale === "zh-CN" ? "当前模型未知" : "Current model unknown", value: "" }] : []), + ...(selectedModel && !model.options.some((option) => option.value === selectedModel) + ? [{ disabled: true, label: `${locale === "zh-CN" ? "当前模型" : "Current model"}: ${selectedModel}`, value: selectedModel }] : []), + ...model.options.map((option) => ({ label: option.label, value: option.value })), + ]} prefixLabel={locale === "zh-CN" ? "模型" : "Model"} value={selectedModel} /> - ) : modelDiscoveryState === "loading" ? {locale === "zh-CN" ? "正在读取模型…" : "Reading models…"} + ) : selectedAgentModelId === "" ? {locale === "zh-CN" ? "当前模型未知" : "Current model unknown"} + : modelDiscoveryState === "loading" ? {locale === "zh-CN" ? "正在读取模型…" : "Reading models…"} : modelDiscoveryState === "error" ? {locale === "zh-CN" ? "无法读取模型" : "Models unavailable"} : null; // The chip reports the selected executor, whose credential pays for it, and // the resolved model, so an executor and a model that disagree are visible diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 89306b709f..4becf04f2f 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1590,11 +1590,10 @@ function PersonalGoalHome({ }, [readOnly, conversationReturnSessionKey]); function recordSessionAdmission(targetContextId: string, session: ChatSessionSummary) { - if (session.agent_model_id) { - const key = agentModelSelectionKey(targetContextId, session.agent_id); - setSelectedAgentModels((current) => current[key] === session.agent_model_id - ? current : { ...current, [key]: session.agent_model_id! }); - } + const key = agentModelSelectionKey(targetContextId, session.agent_id); + const currentModel = session.agent_model_id ?? ""; + setSelectedAgentModels((current) => current[key] === currentModel + ? current : { ...current, [key]: currentModel }); const queues = chatSessionQueuesFollowUps(session); const supportsSteering = chatSessionSupportsSteering(session); setSteeringSessionIds((current) => { @@ -2405,16 +2404,20 @@ function PersonalGoalHome({ // stored identity before offering a link; a failed read never hides the // visible answer or retries the model Turn. const completedMessageId = streamingMessageId; - if ((response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) { - void fetchChatSession(sessionId).then((stored) => { + void fetchChatSession(sessionId).then((stored) => { + const key = `${targetContextId}:${stored.session.agent_id}`; + if (sessionIds.current.get(key) === sessionId && !newSessionRequired.current.has(key)) { + recordSessionAdmission(targetContextId, stored.session); + } + if ((response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) { const answer = stored.messages.find((item) => item.turn_id === streamed.turnId && ["agent", "assistant"].includes(item.role)); if (answer) updateConversationMessage(targetContextId, completedMessageId, { sourceMessageId: answer.message_id, sourceSessionId: sessionId, collaboration: answer.collaboration, returnDelivery: answer.return_delivery, }); - }).catch(() => { /* The original conversation remains readable. */ }); - } + } + }).catch(() => { /* The original conversation remains readable. */ }); const todoProposals = response.proposals.filter(isTodoProposal); // The channel already states where a team plan is confirmed: its answer // names the Goal whose workspace holds the card, so a manager-channel diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index f0def15aff..37e7675bf2 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -251,46 +251,65 @@ def _session_config_options(result: dict[str, Any]) -> dict[str, dict[str, Any]] for item in raw: if not isinstance(item, dict): continue - config_id = str(item.get("id") or "").strip() - if not config_id or len(config_id) > 80: + config_id = str(item.get("id") or "") + if not config_id.strip() or len(config_id) > 80: + continue + if item.get("type", "select") != "select": continue choices: list[dict[str, str]] = [] - for choice in item.get("options") if isinstance(item.get("options"), list) else []: - if not isinstance(choice, dict): - continue - value = str(choice.get("value") or choice.get("id") or "").strip() - label = _compact_label(choice.get("name") or choice.get("label") or value, fallback=value) - if value and len(value) <= 160: + raw_choices = item.get("options") if isinstance(item.get("options"), list) else [] + for entry in raw_choices: + group_choices = entry.get("options") if isinstance(entry, dict) else None + candidates = group_choices if isinstance(group_choices, list) else [entry] + for choice in candidates: + if not isinstance(choice, dict): + continue + value = choice.get("value") + if not isinstance(value, str) or not value or len(value) > 160: + continue + label = _compact_label(choice.get("name") or choice.get("label") or value, fallback=value) choices.append({"value": value, "label": label}) - selected = str(item.get("currentValue") or item.get("value") or "").strip() + selected = str(item.get("currentValue") or item.get("value") or "") options[config_id] = { "label": _compact_label(item.get("name") or item.get("label") or config_id, fallback=config_id), + "category": str(item.get("category") or ""), "selected": selected if len(selected) <= 160 else "", "options": choices, } return options def model_options(self) -> dict[str, Any] | None: - option = self.session_config_options.get("model") - if not option: - return None - return {"id": "model", **option} + for config_id, option in self.session_config_options.items(): + if option["category"] == "model": + return {"id": config_id, "label": option["label"], "selected": option["selected"], "options": option["options"]} + if "model" in self.session_config_options: + option = self.session_config_options["model"] + return {"id": "model", "label": option["label"], "selected": option["selected"], "options": option["options"]} + return None + + @property + def current_model_id(self) -> str | None: + model = self.model_options() + return (str(model["selected"]) or None) if model else None def set_config_values(self, values: dict[str, str]) -> None: """Apply caller-selected ACP options after the session exists.""" for config_id, value in values.items(): + if config_id == "model": + model = self.model_options() + config_id = str(model["id"]) if model else config_id known = self.session_config_options.get(config_id) if known is None: raise self._runtime_error("ACP Agent does not offer the selected configuration.", error_code="unsupported_configuration") allowed = {str(item["value"]) for item in known["options"]} if value not in allowed: raise self._runtime_error("ACP Agent rejected an unavailable configuration value.", error_code="unsupported_configuration") - self._request( + result = self._request( "session/set_config_option", {"sessionId": self.session_id, "configId": config_id, "type": "id", "value": value}, timeout_sec=self.startup_timeout_sec, ) - known["selected"] = value + self.session_config_options = self._session_config_options(result) def _write(self, payload: dict[str, Any]) -> None: if self.process.stdin is None: @@ -415,7 +434,10 @@ def on_notification(payload: dict[str, Any]) -> None: if not isinstance(update, dict): return kind = str(update.get("sessionUpdate") or "") - if kind == "agent_message_chunk": + if kind == "config_option_update": + self.session_config_options = self._session_config_options(update) + event_sink("agent.model_config", {"model_id": self.current_model_id}) + elif kind == "agent_message_chunk": content = update.get("content") if isinstance(content, dict) and content.get("type") == "text": chunk = str(content.get("text") or "") diff --git a/loopx/chat_providers.py b/loopx/chat_providers.py index 5b2ffb10c6..b93bff8125 100644 --- a/loopx/chat_providers.py +++ b/loopx/chat_providers.py @@ -22,6 +22,34 @@ _PRIVATE_PARTS = {".git", ".env", ".ssh", ".aws", ".config", "node_modules"} +def codex_model_options(codex_bin: str) -> dict[str, Any] | None: + """Project the local Codex catalog without exposing its instructions.""" + executable = codex_bin if Path(codex_bin).is_absolute() else shutil.which(codex_bin) + if not executable: + return None + try: + result = subprocess.run( + [str(executable), "debug", "models"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, encoding="utf-8", errors="replace", timeout=15, check=False, + ) + payload = json.loads(result.stdout) if result.returncode == 0 else {} + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + return None + rows = payload.get("models") if isinstance(payload, dict) else None + if not isinstance(rows, list): + return None + options = [ + {"value": slug, "label": " ".join(str(row.get("display_name") or slug).split())[:100]} + for row in rows + if isinstance(row, dict) + and isinstance((slug := str(row.get("slug") or "").strip()), str) + and slug and len(slug) <= 160 + and str(row.get("visibility") or "list") == "list" + ] + return {"id": "model", "label": "Model", "selected": options[0]["value"] if options else "", "options": options} if options else None + + def _read_only_tools(provider: str) -> list[dict[str, Any]]: definitions = [ ("list_files", "List public project files matching a bounded glob.", { diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index bcf62688cd..ec69a15993 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -9,8 +9,6 @@ import logging import os from pathlib import Path -import shutil -import subprocess import threading import time from typing import Any, Callable, Mapping, Protocol @@ -60,7 +58,7 @@ normalize_agent_model_id, utc_now, ) -from .chat_providers import ClaudeCodeAdapter, direct_model_from_environment +from .chat_providers import ClaudeCodeAdapter, codex_model_options, direct_model_from_environment from .attached_session import ( enqueue_attached_agent_turn, require_current_attached_session, @@ -404,7 +402,7 @@ def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[ if capability is None or not capability.get("available"): raise agent_endpoint_error(agent_id, reason=str((capability or {}).get("unavailable_reason") or "")) if agent_id == "codex": - return {"agent_id": agent_id, "model": self._codex_model_options(), "supported": True} + return {"agent_id": agent_id, "model": codex_model_options(self.codex_bin), "supported": True} if agent_id == "claude-code": return { "agent_id": agent_id, @@ -431,33 +429,6 @@ def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[ finally: adapter.close_session() - def _codex_model_options(self) -> dict[str, Any] | None: - """Project the local Codex catalog without exposing its instructions.""" - executable = self.codex_bin if Path(self.codex_bin).is_absolute() else shutil.which(self.codex_bin) - if not executable: - return None - try: - result = subprocess.run( - [str(executable), "debug", "models"], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, encoding="utf-8", errors="replace", timeout=15, check=False, - ) - payload = json.loads(result.stdout) if result.returncode == 0 else {} - except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): - return None - rows = payload.get("models") if isinstance(payload, dict) else None - if not isinstance(rows, list): - return None - options = [ - {"value": slug, "label": " ".join(str(row.get("display_name") or slug).split())[:100]} - for row in rows - if isinstance(row, dict) - and isinstance((slug := str(row.get("slug") or "").strip()), str) - and slug and len(slug) <= 160 - and str(row.get("visibility") or "list") == "list" - ] - return {"id": "model", "label": "Model", "selected": options[0]["value"] if options else "", "options": options} if options else None - @staticmethod def _managed_upstream_mode(session: dict[str, Any]) -> str: if session.get("agent_id") == "codex": @@ -799,7 +770,7 @@ def open_session( codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, project_context=project_context, steward_context=steward_context, - agent_model_id=agent_model_id, + agent_model_id=adapter.current_model_id if isinstance(adapter, ACPStdioAdapter) else agent_model_id, ) except Exception: adapter.close_session() @@ -1059,6 +1030,8 @@ def _ensure_adapter_locked( with self.lock: self.adapters[session_id] = adapter try: + if isinstance(adapter, ACPStdioAdapter): + self.store.update_session(session_id, agent_model_id=adapter.current_model_id) if session.get("agent_id") == "codex" and session.get("codex_home") is None: # A legacy session is bound only after successful upstream resume, # not when a service happens to start in a new environment. @@ -1673,6 +1646,9 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: expected_statuses={"starting", "running", "interrupting"}, upstream_turn_id=payload["upstream_turn_id"], ) + if kind == "agent.model_config": + self.store.update_session(session_id, agent_model_id=payload.get("model_id")) + return with self.lock: if (session_id, turn_id) in self.cancelled_turns: return diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 65d09ff517..20a3ca2340 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -76,8 +76,8 @@ def normalize_agent_model_id(value: Any) -> str: are persisted in JSON only, so retain a bounded printable value while rejecting controls that would make logs or projections unsafe. """ - token = str(value or "").strip() - if not token or len(token) > _AGENT_MODEL_ID_MAX_LENGTH or any(ord(character) < 32 for character in token): + token = str(value or "") + if not token.strip() or len(token) > _AGENT_MODEL_ID_MAX_LENGTH or any(ord(character) < 32 for character in token): raise ValueError("agent_model_id must be a bounded model value") return token From dcd25fe9280e300ce97338dad89ce9d233c451b8 Mon Sep 17 00:00:00 2001 From: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:13:34 +0800 Subject: [PATCH 07/11] test(chat): cover model recovery and refresh validation baselines Signed-off-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- examples/loopx-chat-acp-adapter-smoke.py | 61 +++++++++++++++---- loopx/canary/module_metric_baseline.json | 7 ++- .../project_registry_io_manifest_v1.json | 8 +-- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/examples/loopx-chat-acp-adapter-smoke.py b/examples/loopx-chat-acp-adapter-smoke.py index b210c539c8..0c47bba404 100644 --- a/examples/loopx-chat-acp-adapter-smoke.py +++ b/examples/loopx-chat-acp-adapter-smoke.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.chat_acp import ACPStdioAdapter # noqa: E402 +from loopx.chat_agent import CodexChatAgentError # noqa: E402 from loopx.chat_endpoints import AgentEndpointRegistry # noqa: E402 from loopx.chat_runtime import ChatRuntimeController # noqa: E402 from loopx.chat_store import ChatSessionStore # noqa: E402 @@ -30,6 +31,13 @@ active_prompt_id = None session_cwd = "" client_initialized = False +current_model = "fixture-default" +def config_options(): + return [{"id": "engine", "name": "Model", "category": "model", "type": "select", "currentValue": current_model, + "options": [{"group": "fixtures", "name": "Fixtures", "options": [ + {"value": "fixture-default", "name": "Fixture default"}, + {"value": "fixture/fast", "name": "Fixture fast"}, + ]}]}] for line in sys.stdin: request = json.loads(line) assert request.get("jsonrpc") == "2.0", request @@ -53,14 +61,12 @@ assert client_initialized, "client must send initialized before session/new" assert request["params"]["mcpServers"] == [] session_cwd = request["params"]["cwd"] - result = {"sessionId": session_id, "configOptions": [{ - "id": "model", "name": "Model", "currentValue": "fixture-default", - "options": [{"value": "fixture-default", "name": "Fixture default"}, {"value": "fixture/fast", "name": "Fixture fast"}], - }]} + result = {"sessionId": session_id, "configOptions": config_options()} elif method == "session/load": assert request["params"]["sessionId"] == session_id session_cwd = request["params"]["cwd"] - result = {} + current_model = "fixture-default" + result = {} if "--omit-config-on-load" in sys.argv else {"configOptions": config_options()} elif method == "session/prompt": active_prompt_id = request_id prompt = request["params"]["prompt"][0]["text"] @@ -74,6 +80,11 @@ assert "Do not edit files" in prompt, prompt if "wait for cancel" in prompt: continue + if "provider fallback" in prompt: + current_model = "fixture-default" + print(json.dumps({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, "update": {"sessionUpdate": "config_option_update", "configOptions": config_options()} + }}), flush=True) if "activity renew" in prompt: for _ in range(4): time.sleep(0.06) @@ -117,13 +128,14 @@ permission = json.loads(sys.stdin.readline()) assert permission["id"] == 900 assert permission["result"]["outcome"]["outcome"] == "cancelled" + answer = "ACP model: " + current_model if "report model" in prompt else "ACP 回答。" envelope = '' + json.dumps({ "schema_version": "loopx_chat_agent_response_v0", - "message": "ACP 回答。", + "message": answer, "proposals": [], "gate": None, }, ensure_ascii=False) + '' - response = "ACP 回答。\n" + envelope + response = answer + "\n" + envelope marker_split = response.index("") + 5 for chunk in (response[:marker_split], response[marker_split:]): print(json.dumps({ @@ -136,8 +148,9 @@ }, ensure_ascii=False), flush=True) result = {"stopReason": "end_turn"} elif method == "session/set_config_option": - assert request["params"] == {"sessionId": session_id, "configId": "model", "type": "id", "value": "fixture/fast"} - result = {} + assert request["params"] == {"sessionId": session_id, "configId": "engine", "type": "id", "value": "fixture/fast"} + current_model = "fixture/fast" + result = {"configOptions": config_options()} elif method == "session/cancel": if active_prompt_id is not None: print(json.dumps({"jsonrpc": "2.0", "id": active_prompt_id, "result": {"stopReason": "cancelled"}}), flush=True) @@ -162,7 +175,7 @@ def main() -> None: assert adapter.upstream_thread_id == "acp:fixture/session" assert adapter.capabilities()["resume"] is True assert adapter.model_options() == { - "id": "model", "label": "Model", "selected": "fixture/fast", + "id": "engine", "label": "Model", "selected": "fixture/fast", "options": [{"value": "fixture-default", "label": "Fixture default"}, {"value": "fixture/fast", "label": "Fixture fast"}], } events: list[tuple[str, dict[str, object]]] = [] @@ -180,6 +193,20 @@ def main() -> None: assert not any("private thought" in str(payload) for _, payload in events), events adapter.close_session() + try: + ACPStdioAdapter.start(command=(str(fake),), work_dir=root, config_values={"model": "not-offered"}) + except CodexChatAgentError as exc: + assert exc.error_code == "unsupported_configuration", exc + else: + raise AssertionError("an unknown model was accepted") + + unknown = ACPStdioAdapter.start( + command=(str(fake), "--omit-config-on-load"), work_dir=root, + resume_thread_id="acp:fixture/session", + ) + assert unknown.current_model_id is None + unknown.close_session() + renewed = ACPStdioAdapter.start( command=(str(fake),), work_dir=root, @@ -224,6 +251,9 @@ def main() -> None: codex_bin="missing-codex-for-fixture", endpoint_registry=endpoint_registry, ) + discovered = first.inspect_agent_model_options("fixture-acp", work_dir=root) + assert discovered["model"]["id"] == "engine", discovered + assert {item["value"] for item in discovered["model"]["options"]} == {"fixture-default", "fixture/fast"} session, was_resumed = first.open_session( goal_id="fixture-goal", agent_id="fixture-acp", @@ -239,7 +269,7 @@ def main() -> None: turn, created = first.submit_turn( session_id=str(session["session_id"]), client_turn_id="fixture-turn", - message="verify execution mode", + message="verify execution mode; report model", work_dir=root, objective="Exercise the ACP route.", ) @@ -250,6 +280,7 @@ def main() -> None: timeout_sec=3, ) assert completed["status"] == "completed", completed + assert completed["response"]["message"] == "ACP model: fixture/fast", completed first.close() second = ChatRuntimeController( store=store, @@ -266,6 +297,14 @@ def main() -> None: ) assert was_resumed is True assert restored["session_id"] == session["session_id"] + assert restored["agent_model_id"] == "fixture-default", restored + fallback_turn, _ = second.submit_turn( + session_id=str(session["session_id"]), client_turn_id="fallback-turn", + message="provider fallback; report model", work_dir=root, objective="Exercise the ACP route.", + ) + fallback = second.wait_for_turn(session_id=str(session["session_id"]), turn_id=str(fallback_turn["turn_id"]), timeout_sec=3) + assert fallback["response"]["message"] == "ACP model: fixture-default", fallback + assert store.load_session(str(session["session_id"]))["agent_model_id"] == "fixture-default" second.close() # Kiro CLI is a built-in ACP agent, not an owner-registered endpoint: diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 0fc6ff3c7f..4bf45b00fe 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -31,14 +31,17 @@ "dict_any_count": 0 }, "loopx/chat_runtime.py": { - "any_count": 36, + "any_count": 37, "dict_any_count": 0, - "lines": 2137 + "lines": 2210 }, "loopx/chat_server.py": { "any_count": 25, "dict_any_count": 0 }, + "loopx/chat_store.py": { + "lines": 2015 + }, "loopx/codex_cli_probe.py": { "any_count": 37, "dict_any_count": 0 diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 984549e2f0..7f910d1f20 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -519,7 +519,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 1013, + "line": 1039, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -527,7 +527,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 540, + "line": 541, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -535,7 +535,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1592, + "line": 1619, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -543,7 +543,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1703, + "line": 1730, "column": 20, "kind": "codec_read", "api": "load_registry", From 295258989e7dfd5ef689ded3eea4166dc1d0e7b9 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:00:46 +0800 Subject: [PATCH 08/11] fix(chat): preserve explicit ACP startup limits Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- loopx/chat_acp.py | 20 ++++++++++++++------ loopx/chat_runtime.py | 12 ++++++------ loopx/chat_server.py | 2 +- loopx/cli_commands/support_control.py | 6 +++++- loopx/cli_commands/support_control_chat.py | 6 ++++-- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index 92278bf9eb..0a42a97da6 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -29,7 +29,8 @@ EventSink = Callable[[str, dict[str, Any]], None] ACP_PROTOCOL_VERSION = 1 -ACP_SESSION_STARTUP_TIMEOUT_FLOOR_SEC = 45.0 +ACP_INITIALIZE_TIMEOUT_DEFAULT_SEC = 30.0 +ACP_SESSION_STARTUP_TIMEOUT_DEFAULT_SEC = 45.0 def _reader(stream: Any, messages: "queue.Queue[dict[str, Any] | Exception]") -> None: @@ -85,7 +86,7 @@ def start( work_dir: Path, agent_work_dir: Path | None = None, resume_thread_id: str | None = None, - startup_timeout_sec: float = 30.0, + startup_timeout_sec: float | None = None, idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, execution_mode: bool = False, @@ -118,6 +119,11 @@ def start( messages: "queue.Queue[dict[str, Any] | Exception]" = queue.Queue() assert process.stdout is not None threading.Thread(target=_reader, args=(process.stdout, messages), daemon=True).start() + initialize_timeout_sec = ( + ACP_INITIALIZE_TIMEOUT_DEFAULT_SEC + if startup_timeout_sec is None + else startup_timeout_sec + ) adapter = cls( process=process, messages=messages, @@ -126,7 +132,7 @@ def start( agent_work_dir=agent_work_dir or work_dir.expanduser().resolve(), agent_capabilities={}, execution_mode=execution_mode, - startup_timeout_sec=startup_timeout_sec, + startup_timeout_sec=initialize_timeout_sec, idle_timeout_sec=idle_timeout_sec, hard_timeout_sec=hard_timeout_sec, ) @@ -146,7 +152,7 @@ def start( }, }, request_id=1, - timeout_sec=startup_timeout_sec, + timeout_sec=initialize_timeout_sec, ) if int(initialized.get("protocolVersion") or 0) != ACP_PROTOCOL_VERSION: raise adapter._runtime_error("ACP Agent selected an unsupported protocol version.") @@ -155,8 +161,10 @@ def start( # ACP agents may defer session creation until the client confirms # initialization before accepting ``session/new``. adapter._notify("initialized", {}) - session_startup_timeout_sec = max( - startup_timeout_sec, ACP_SESSION_STARTUP_TIMEOUT_FLOOR_SEC + session_startup_timeout_sec = ( + ACP_SESSION_STARTUP_TIMEOUT_DEFAULT_SEC + if startup_timeout_sec is None + else startup_timeout_sec ) session_params = {"cwd": str(adapter.agent_work_dir), "mcpServers": []} if resume_thread_id: diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 157d1e089d..966d3cb38c 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -315,7 +315,7 @@ def __init__( codex_bin: str, claude_bin: str = "claude", kiro_cli_bin: str = KIRO_CLI_BIN, - startup_timeout_sec: float = 30.0, + startup_timeout_sec: float | None = None, idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, endpoint_registry: AgentEndpointRegistry | None = None, @@ -336,9 +336,9 @@ def __init__( or os.environ.get("CODEX_HOME") or "~/.codex" ).expanduser().resolve() - self.claude_bin = claude_bin - self.kiro_cli_bin = kiro_cli_bin - self.startup_timeout_sec = startup_timeout_sec + self.claude_bin, self.kiro_cli_bin = claude_bin, kiro_cli_bin + self.acp_startup_timeout_sec = startup_timeout_sec + self.startup_timeout_sec = 30.0 if startup_timeout_sec is None else startup_timeout_sec self.idle_timeout_sec = idle_timeout_sec self.hard_timeout_sec = hard_timeout_sec self.endpoint_registry = endpoint_registry or AgentEndpointRegistry(store.root) @@ -561,7 +561,7 @@ def _start_adapter( command=kiro_cli_chat_command(self.kiro_cli_bin), work_dir=work_dir, resume_thread_id=resume_thread_id, - startup_timeout_sec=self.startup_timeout_sec, + startup_timeout_sec=self.acp_startup_timeout_sec, idle_timeout_sec=self.idle_timeout_sec, hard_timeout_sec=self.hard_timeout_sec, execution_mode=execution_mode, @@ -573,7 +573,7 @@ def _start_adapter( work_dir=work_dir, agent_work_dir=endpoint.mapped_work_dir(work_dir), resume_thread_id=resume_thread_id, - startup_timeout_sec=self.startup_timeout_sec, + startup_timeout_sec=self.acp_startup_timeout_sec, idle_timeout_sec=self.idle_timeout_sec, hard_timeout_sec=self.hard_timeout_sec, execution_mode=execution_mode, diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 9bfeb40d8d..88b48ea138 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1566,7 +1566,7 @@ def serve_chat( claude_bin: str = "claude", kiro_cli_bin: str = KIRO_CLI_BIN, lark_cli_bin: str | None = None, - startup_timeout_sec: float = 30.0, + startup_timeout_sec: float | None = None, idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, assets_dir: Path | None = None, diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index b99e6a5e6f..df8ea7a8c6 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -642,7 +642,11 @@ def handle_support_control_command( claude_bin=args.claude_bin, kiro_cli_bin=getattr(args, "kiro_cli_bin", KIRO_CLI_BIN), lark_cli_bin=args.lark_cli_bin, - startup_timeout_sec=max(0.1, float(args.startup_timeout_seconds)), + startup_timeout_sec=( + max(0.1, float(args.startup_timeout_seconds)) + if args.startup_timeout_seconds is not None + else None + ), idle_timeout_sec=max(0.1, float(args.idle_timeout_seconds)), hard_timeout_sec=max(0.1, float(args.hard_timeout_seconds)), assets_dir=Path(args.assets_dir).expanduser() diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py index 45dd4d962c..bd8f5cff15 100644 --- a/loopx/cli_commands/support_control_chat.py +++ b/loopx/cli_commands/support_control_chat.py @@ -36,8 +36,10 @@ def register_chat_and_dashboard_commands( chat_parser.add_argument( "--startup-timeout-seconds", type=float, - default=30.0, - help="Maximum seconds allowed for Codex app-server startup and handshake.", + help=( + "Maximum seconds allowed for Agent startup and session creation. " + "Defaults to 30 seconds for Codex startup and 45 seconds for ACP session creation." + ), ) chat_parser.add_argument( "--idle-timeout-seconds", From 6bfdb64c7d70d508cef6fcc982cf047b3588a8c3 Mon Sep 17 00:00:00 2001 From: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:00:49 +0800 Subject: [PATCH 09/11] test(chat): cover ACP startup timeout policy Signed-off-by: sakurahello1 <201035361+sakurahello1@users.noreply.github.com> --- tests/test_chat_acp_startup_timeout.py | 174 +++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/test_chat_acp_startup_timeout.py diff --git a/tests/test_chat_acp_startup_timeout.py b/tests/test_chat_acp_startup_timeout.py new file mode 100644 index 0000000000..be2b2771ee --- /dev/null +++ b/tests/test_chat_acp_startup_timeout.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys + +import pytest + +from loopx.chat_acp import ACPStdioAdapter +from loopx.chat_endpoints import AgentEndpointRegistry +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore + + +SLOW_SESSION_ACP = r''' +import json +import os +from pathlib import Path +import sys +import time + +delay = float(sys.argv[1]) +Path(sys.argv[2]).write_text(str(os.getpid()), encoding="utf-8") +session_method = sys.argv[3] +for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + request_id = request.get("id") + if method == "initialize": + capabilities = ( + {"loadSession": True} + if session_method == "session/load" + else {"sessionCapabilities": {"resume": {}}} + if session_method == "session/resume" + else {} + ) + result = {"protocolVersion": 1, "agentCapabilities": capabilities} + elif method == "initialized": + continue + elif method == session_method: + time.sleep(delay) + result = {"sessionId": "acp:fixture/slow-session"} + elif method == "session/close": + result = {} + else: + continue + print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}), flush=True) +''' + + +def _command( + tmp_path: Path, + *, + name: str, + session_method: str = "session/new", +) -> tuple[tuple[str, ...], Path]: + agent = tmp_path / "slow-session-acp.py" + agent.write_text(SLOW_SESSION_ACP, encoding="utf-8") + pid_path = tmp_path / f"{name}.pid" + return ( + sys.executable, + str(agent), + "0.15", + str(pid_path), + session_method, + ), pid_path + + +def test_acp_default_and_larger_session_startup_windows_accept_slow_peer( + tmp_path: Path, +) -> None: + default_command, _ = _command(tmp_path, name="default") + default_adapter = ACPStdioAdapter.start(command=default_command, work_dir=tmp_path) + assert default_adapter.upstream_thread_id == "acp:fixture/slow-session" + default_adapter.close_session() + + longer_command, _ = _command(tmp_path, name="longer") + longer_adapter = ACPStdioAdapter.start( + command=longer_command, + work_dir=tmp_path, + startup_timeout_sec=0.5, + ) + assert longer_adapter.upstream_thread_id == "acp:fixture/slow-session" + longer_adapter.close_session() + + +@pytest.mark.parametrize( + ("session_method", "resume_thread_id"), + [ + ("session/new", None), + ("session/load", "acp:fixture/slow-session"), + ("session/resume", "acp:fixture/slow-session"), + ], +) +def test_acp_explicit_short_startup_limit_rejects_and_reaps_peer( + tmp_path: Path, + session_method: str, + resume_thread_id: str | None, +) -> None: + command, pid_path = _command( + tmp_path, + name=session_method.replace("/", "-"), + session_method=session_method, + ) + with pytest.raises(TimeoutError, match=session_method): + ACPStdioAdapter.start( + command=command, + work_dir=tmp_path, + resume_thread_id=resume_thread_id, + startup_timeout_sec=0.05, + ) + + process_id = int(pid_path.read_text(encoding="utf-8")) + with pytest.raises(ProcessLookupError): + os.kill(process_id, 0) + + +def test_chat_runtime_uses_default_acp_session_startup_window(tmp_path: Path) -> None: + store = ChatSessionStore(tmp_path / "runtime") + registry = AgentEndpointRegistry(store.root) + command, _ = _command(tmp_path, name="controller-default") + registry.upsert( + { + "agent_id": "slow-default-acp", + "display_name": "Slow default ACP", + "command": list(command), + "location": "local", + } + ) + controller = ChatRuntimeController( + store=store, + codex_bin="missing-codex-for-fixture", + endpoint_registry=registry, + ) + + session, was_resumed = controller.open_session( + goal_id="fixture-default-timeout", + agent_id="slow-default-acp", + work_dir=tmp_path, + objective="Exercise the default ACP session startup window.", + mode="new", + ) + assert was_resumed is False + controller.close_session(str(session["session_id"])) + + +def test_chat_runtime_preserves_explicit_acp_startup_limit(tmp_path: Path) -> None: + store = ChatSessionStore(tmp_path / "runtime") + registry = AgentEndpointRegistry(store.root) + command, _ = _command(tmp_path, name="controller-short") + registry.upsert( + { + "agent_id": "slow-short-acp", + "display_name": "Slow short ACP", + "command": list(command), + "location": "local", + } + ) + controller = ChatRuntimeController( + store=store, + codex_bin="missing-codex-for-fixture", + endpoint_registry=registry, + startup_timeout_sec=0.05, + ) + + with pytest.raises(TimeoutError, match="session/new"): + controller.open_session( + goal_id="fixture-short-timeout", + agent_id="slow-short-acp", + work_dir=tmp_path, + objective="Exercise an explicit ACP session startup limit.", + mode="new", + ) + assert not store.list_sessions(agent_id="slow-short-acp") From 209004481af52e3c348c00893ca49ca0f80125d5 Mon Sep 17 00:00:00 2001 From: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:29:04 +0800 Subject: [PATCH 10/11] fix(chat): preserve exact models and unchanged session selections Signed-off-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 2 +- apps/presentation/dashboard/src/views/dashboard-page.tsx | 1 + loopx/chat_server.py | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index dc02e8a7fd..e189dda697 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -730,7 +730,7 @@ export type AgentModelDiscovery = { schema_version: "loopx_chat_agent_model_options_v1"; agent_id: string; supported: boolean; - model: { id: "model"; label: string; selected: string; options: AgentModelOption[] } | null; + model: { id: string; label: string; selected: string; options: AgentModelOption[] } | null; }; export function discoverAgentModelOptions(agentId: string, signal?: AbortSignal) { diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 4becf04f2f..3ede6995c1 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -2651,6 +2651,7 @@ function PersonalGoalHome({ function chooseAgentModel(agentId: string, modelId: string) { const selectionKey = agentModelSelectionKey(contextId, agentId); + if (selectedAgentModels[selectionKey] === modelId) return; setSelectedAgentModels((current) => current[selectionKey] === modelId ? current : { ...current, [selectionKey]: modelId }); const key = `${contextId}:${agentId}`; sessionIds.current.delete(key); diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 7617114e22..df3f7d227e 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -47,7 +47,7 @@ ) from .chat_session_open import open_chat_session from .chat_ssh_source_api import SshSourceRequestMixin -from .chat_store import ChatSessionStore +from .chat_store import ChatSessionStore, normalize_agent_model_id from .extensions.lark.private_conversation_api import PrivateConversationRequestMixin, PRIVATE_CONVERSATIONS_PATH from .extensions.lark.conversation_identity import observe_lark_conversation_identity from .extensions.lark.private_conversations import LarkPrivateConversations @@ -592,7 +592,8 @@ def _create_session(self) -> None: # own default through its own owner instead of inheriting whatever # executor this client happens to ship with. requested_endpoint = _compact_text(body.get("agent_id"), limit=80) - requested_model = _compact_text(body.get("agent_model_id"), limit=160) + model_value = body.get("agent_model_id") + requested_model = normalize_agent_model_id(model_value) if model_value not in (None, "") else None mode = _compact_text(body.get("mode"), limit=40) or "resume_latest" context_kind = _compact_text(body.get("context_kind"), limit=40) or "goal" if context_kind not in {"goal", "manager", "project"}: From e9b0989e1e8df5dad43c0f1fbbd301fda2314686 Mon Sep 17 00:00:00 2001 From: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:29:04 +0800 Subject: [PATCH 11/11] test(chat): guard exact HTTP model values and session continuity Signed-off-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- examples/personal-workspace-browser-smoke.mjs | 2 + .../agent-model-selection.mjs | 75 +++++++++++++++++++ .../project_registry_io_manifest_v1.json | 6 +- tests/test_chat_agent_model_http.py | 66 ++++++++++++++++ tests/test_chat_store_input_validation.py | 1 + 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 examples/personal-workspace-browser/agent-model-selection.mjs create mode 100644 tests/test_chat_agent_model_http.py diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 886c50164a..c075e28554 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-workspace-create.mjs"; +import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs"; import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs"; import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; @@ -106,6 +107,7 @@ scenarioCatalog.push(goalWorkspaceM2Scenario); scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; +scenarioCatalog.push(agentModelSelectionScenario); const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) : scenarioCatalog; diff --git a/examples/personal-workspace-browser/agent-model-selection.mjs b/examples/personal-workspace-browser/agent-model-selection.mjs new file mode 100644 index 0000000000..3639bbf305 --- /dev/null +++ b/examples/personal-workspace-browser/agent-model-selection.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// Compiled UI with synthetic HTTP Session storage; ACP adoption has its own stdio smoke. +export const agentModelSelectionScenario = { + id: "agent-model-selection", + async run({ browser, collectCoverage, url }) { + let createdCount = 0; + const context = await openWorkspacePage(browser, url, { + collectCoverage, + beforeGoto: async (_api, page) => { + await page.route("**/api/chat/agent-model-options", route => route.fulfill({ json: { + ok: true, schema_version: "loopx_chat_agent_model_options_v1", agent_id: "codex", supported: true, + model: { id: "engine", label: "Model", selected: "safe", options: [ + { value: "safe", label: "Safe" }, { value: "vendor/model fast", label: "Fast" }, + ] }, + } })); + await page.route("**/api/chat/sessions", async route => { + if (route.request().method() !== "POST") return route.fallback(); + const body = route.request().postDataJSON(); + const runtime = page.__loopxRuntime; + const channel = body.context_kind === "manager" ? "manager" : `goal.${body.goal_id}`; + const existing = body.mode === "resume_latest" ? [...runtime.sessions.values()].find(session => + session.channel_id === channel && session.agent_id === body.agent_id) : null; + const session = existing ?? { + session_id: `model-session-${++createdCount}`, goal_id: body.goal_id, agent_id: body.agent_id, + agent_model_id: body.agent_model_id ?? "safe", adapter_kind: "codex_app_server", channel_id: channel, + status: "ready", active_turn_id: null, last_error_code: null, resumable: true, + created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:00Z", + }; + runtime.sessions.set(session.session_id, session); + runtime.messages.set(session.session_id, runtime.messages.get(session.session_id) ?? []); + await route.fulfill({ status: existing ? 200 : 201, json: { + ok: true, session_id: session.session_id, session, resumed: Boolean(existing), agent_id: session.agent_id, + } }); + }); + }, + }); + const { page, api } = context; + try { + await page.locator(".personal-goal-link").first().click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + const selectModel = async label => { + await page.getByRole("combobox", { name: "选择模型" }).click(); + await page.getByRole("option", { name: label, exact: true }).click(); + }; + const send = async message => { + const count = api.turnRequests.length; + await page.getByLabel("向 LoopX 发送消息").fill(message); + await page.getByRole("button", { name: "发送", exact: true }).click(); + const deadline = Date.now() + 5000; + while (api.turnRequests.length === count && Date.now() < deadline) await page.waitForTimeout(25); + assert.equal(api.turnRequests.length, count + 1); + await page.locator(".personal-message-pending").waitFor({ state: "hidden" }); + return api.turnRequests.at(-1).sessionId; + }; + await selectModel("Safe"); + const original = await send("解释模型选择如何保持当前对话。"); + await page.getByRole("combobox", { name: "选择模型" }).filter({ hasText: "Safe" }).waitFor(); + const before = createdCount; + await selectModel("Safe"); + assert.equal(await send("继续同一个对话。"), original); + assert.equal(createdCount, before, "reselecting the current model must not create another Session"); + await selectModel("Fast"); + const changed = await send("使用新模型继续分析。"); + assert.notEqual(changed, original); + assert.equal(createdCount, before + 1, "a real model change creates exactly one distinct Session"); + assert.equal(page.__loopxRuntime.sessions.get(changed).agent_model_id, "vendor/model fast"); + assert.ok(page.__loopxRuntime.sessions.has(original), "prior history remains addressable"); + return { coverageEntries: context.coverageEntries, note: "Same-model selection preserves Session identity; a changed exact model value creates one distinct Session through the compiled UI and synthetic HTTP store." }; + } finally { + await context.close(); + } + }, +}; diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 7f910d1f20..908b055460 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -519,7 +519,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 1039, + "line": 1040, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -535,7 +535,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1619, + "line": 1620, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -543,7 +543,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1730, + "line": 1731, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/test_chat_agent_model_http.py b/tests/test_chat_agent_model_http.py new file mode 100644 index 0000000000..8b616f5010 --- /dev/null +++ b/tests/test_chat_agent_model_http.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import http.client +import json +from pathlib import Path +import threading +from types import SimpleNamespace + +import pytest + +import loopx.chat_server as chat_server +from loopx.chat_store import ChatSessionStore + + +@pytest.mark.parametrize("value", ["vendor/model fast", "vendor/model-fast", " vendor/model "]) +def test_http_session_preserves_exact_model_value(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + status, payload, received = _create_session(tmp_path, monkeypatch, value) + assert status == 201, payload + assert received == [value] + assert payload["session"]["agent_model_id"] == value + + +@pytest.mark.parametrize("value", ["model\nnext", "model\tfast", "x" * 161, " "]) +def test_http_session_rejects_invalid_model_before_start(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + status, payload, received = _create_session(tmp_path, monkeypatch, value) + assert status == 400, payload + assert received == [] + assert "agent_model_id" in payload["error"] + + +def _create_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str) -> tuple[int, dict, list[str]]: + received: list[str] = [] + store = ChatSessionStore(tmp_path / "runtime") + monkeypatch.setattr(chat_server.ChatRequestHandler, "_registry_and_goal", lambda _self, _goal: ({}, {})) + monkeypatch.setattr(chat_server, "_goal_public_context", lambda *_args: { + "project": tmp_path, "objective": "Validate model input.", "title": "Fixture", + }) + + def open_session(**kwargs): + model = kwargs["agent_model_id"] + received.append(model) + return store.create_session( + goal_id="fixture", agent_id="fixture-acp", adapter_kind="acp", + upstream_thread_id="fixture-thread", agent_model_id=model, + ), False + + monkeypatch.setattr(chat_server, "open_chat_session", open_session) + server = chat_server.ChatHTTPServer(("127.0.0.1", 0), chat_server.ChatRequestHandler) + server.verbose = False + server.selected_goal_id = "fixture" + server.chat_store = store + server.runtime_controller = SimpleNamespace(close=lambda: None) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection("127.0.0.1", server.server_address[1], timeout=5) + try: + connection.request("POST", "/api/chat/sessions", body=json.dumps({ + "goal_id": "fixture", "agent_id": "fixture-acp", "agent_model_id": value, "mode": "new", + }), headers={"Content-Type": "application/json", "Origin": "http://127.0.0.1"}) + response = connection.getresponse() + return response.status, json.loads(response.read()), received + finally: + connection.close() + server.shutdown() + thread.join(timeout=5) + server.server_close() diff --git a/tests/test_chat_store_input_validation.py b/tests/test_chat_store_input_validation.py index 8deb00db31..499b79467c 100644 --- a/tests/test_chat_store_input_validation.py +++ b/tests/test_chat_store_input_validation.py @@ -11,6 +11,7 @@ def test_agent_model_value_keeps_provider_protocol_characters() -> None: assert chat_store.normalize_agent_model_id("vendor/model-fast") == "vendor/model-fast" + assert chat_store.normalize_agent_model_id("vendor/model fast") == "vendor/model fast" @pytest.mark.parametrize("value", ["", "model\nnext", "x" * 161])