diff --git a/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs b/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs index 3cba594069..b7a4888164 100644 --- a/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs +++ b/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs @@ -22,8 +22,18 @@ try { assert.match(summary, /high/); assert.match(summary, /Fast/); assert.doesNotMatch(summary, /next-model|low/, "Pending new settings never masquerade as the current Session"); - assert.match(actual, new RegExp(locale === "zh-CN" ? "新对话配置" : "New conversation settings")); - assert.match(actual, new RegExp(locale === "zh-CN" ? "保留原历史和活动回合" : "existing history and active work are preserved")); + assert.match(actual, new RegExp(locale === "zh-CN" ? "后续消息" : "Subsequent messages")); + assert.match(actual, new RegExp(locale === "zh-CN" ? "当前回合和原历史不变" : "active work and history stay unchanged")); + const saved = render({readback: {agent_model_id: "actual-model", agent_reasoning_effort: "high", + requested_agent_model_id: "saved-model", requested_agent_reasoning_effort: "low", model_settings_supported: true}}); + const savedSummary = saved.slice(saved.indexOf("")); + assert.match(savedSummary, /saved-model/); + assert.doesNotMatch(savedSummary, /actual-model|next-model/); + assert.match(saved, /actual-model/, "The prior actual configuration remains visible inside the menu"); + assert.match(savedSummary, new RegExp(locale === "zh-CN" ? "已保存,后续消息使用" : "Saved for subsequent messages")); + const owned = render({readback: {agent_model_id: "host-model", model_settings_supported: false}}); + assert.match(owned, new RegExp(locale === "zh-CN" ? "原宿主或专属设置" : "owning host or dedicated settings")); + assert.doesNotMatch(owned, /
")); assert.match(unknownSummary, new RegExp(locale === "zh-CN" ? "模型设置" : "Model settings")); @@ -32,7 +42,7 @@ try { assert.doesNotMatch(unknownSummary, /next-model|low|Fast/, "Neither defaults nor a pending choice fill an unknown observation"); const partial = render({ readback: { agent_reasoning_effort: "high", agent_service_tier: "priority" } }); assert.doesNotMatch(partial.slice(partial.indexOf("")), /high|Fast/, "Partial settings do not decorate a missing model as a complete current binding"); - assert.doesNotMatch(render({ ordinary: false }), new RegExp(locale === "zh-CN" ? "新对话配置" : "New conversation settings"), "Steward settings keep their existing owner"); + assert.doesNotMatch(render({ ordinary: false }), new RegExp(locale === "zh-CN" ? "后续消息" : "Subsequent messages"), "Steward settings keep their existing owner"); const defaultsLabel = locale === "zh-CN" ? "默认对话配置" : "Conversation defaults"; assert.match(render({ onOpenDefaults() {} }), new RegExp(defaultsLabel), "Defaults remain discoverable inside the relevant settings disclosure"); assert.doesNotMatch(render({ ordinary: false, onOpenDefaults() {} }), new RegExp(defaultsLabel), "Ordinary defaults are not offered for the steward binding"); @@ -64,8 +74,7 @@ try { const restricted = header("idle", { ...stewardProps, managerRuntime: { runtime_profile: "restricted", sandbox: "read-only", status: "ready" } }); assert.match(restricted, /只读|Read-only/); const goal = header("idle", { ...stewardProps, selectedGoal: { goalId: "public-case", title: "Public Goal", agentLanes: [] } }); - assert.match(goal, /goal-only-model/); - assert.match(goal, /Fast/); + assert.doesNotMatch(goal, /goal-only-model|Fast|personal-chat-model-menu/, "The Goal reading header leaves sending choices in the single composer"); assert.doesNotMatch(goal, /gpt-6\.1-sol/, "Goal readback does not inherit the steward model"); const linked = header("idle", { readOnlySourceLabel: "Historical run", selectedGoal: { goalId: "source-case", title: "Historical run", agentLanes: [] } }); const sourceBadge = linked.match(/class="personal-read-only-source"[^>]*>(.*?)<\/span>/)?.[1] ?? ""; diff --git a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs index 639eeb3742..df22594839 100644 --- a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs @@ -22,7 +22,7 @@ const packetLog = join(root, "native-packets.jsonl"); const written = spawnSync(python, ["-c", [ "import pathlib, runpy, sys", "source = runpy.run_path('examples/loopx-chat-runtime-smoke.py')['FAKE_CODEX']", - "source = source.replace('active_turn = None', 'if sys.argv[1:] == [\"debug\", \"models\"]:\\n print(json.dumps({\\'models\\': [{\\'slug\\': \\'trial-native-model\\', \\'display_name\\': \\'Trial native model\\', \\'supported_reasoning_levels\\': [{\\'effort\\': \\'low\\'}, {\\'effort\\': \\'high\\'}]}, {\\'slug\\': \\'rejected-model\\', \\'display_name\\': \\'Rejected model\\', \\'supported_reasoning_levels\\': [{\\'effort\\': \\'high\\'}]}]}))\\n sys.exit(0)\\nactive_turn = None', 1)", + "source = source.replace('active_turn = None', 'if sys.argv[1:] == [\"debug\", \"models\"]:\\n print(json.dumps({\\'models\\': [{\\'slug\\': \\'trial-native-model\\', \\'display_name\\': \\'Trial native model\\', \\'supported_reasoning_levels\\': [{\\'effort\\': \\'low\\'}, {\\'effort\\': \\'high\\'}, {\\'effort\\': \\'xhigh\\'}]}, {\\'slug\\': \\'rejected-model\\', \\'display_name\\': \\'Rejected model\\', \\'supported_reasoning_levels\\': [{\\'effort\\': \\'high\\'}]}]}))\\n sys.exit(0)\\nactive_turn = None', 1)", "source = source.replace(' method = request.get(\"method\")', f' with open({sys.argv[2]!r}, \"a\") as output:\\n output.write(json.dumps(request) + \"\\\\n\")\\n method = request.get(\"method\")')", "source = source.replace(' result = {\"thread\": {\"id\": \"durable-thread\"}}', ' result = {\"thread\": {\"id\": \"durable-thread\"}, \"model\": \"rejected-readback\" if request[\"params\"].get(\"model\") == \"rejected-model\" else request[\"params\"].get(\"model\", \"old-model\"), \"reasoningEffort\": request[\"params\"].get(\"config\", {}).get(\"model_reasoning_effort\", \"xhigh\")}')", "path = pathlib.Path(sys.argv[1]); path.write_text(source); path.chmod(0o700)", @@ -137,43 +137,160 @@ try { assert.equal(await actions.evaluate(node => node === document.activeElement), true); assert.equal(await composer.inputValue(), "保留草稿,先核对配置。"); assert.equal(sessionRequests.length, beforeActions, "Menu open/dismiss is read-only"); + const sessionsBeforeSettings = (await (await page.request.get(`${url}../api/chat/sessions`)).json()).sessions.map(row => row.session_id).sort(); + const packetsBefore = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); await menu.locator("summary").click(); + const modelSaved = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.request().method() === "POST"); await page.getByRole("combobox", {name: "选择模型", exact: true}).click(); await page.getByRole("option", {name: "Trial native model", exact: true}).click(); + const modelResponse = await modelSaved; + assert.equal(modelResponse.status(), 200, await modelResponse.text()); + const configured = (await modelResponse.json()).session; + const sessionId = configured.session_id; + assert.ok(sessionsBeforeSettings.includes(sessionId), "The selector updates an existing Session"); + assert.equal(configured.requested_agent_model_id, "trial-native-model"); + assert.equal(configured.agent_model_id, "old-model", "Saving the next selection does not relabel prior native work"); + const effortSaved = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.request().method() === "POST"); await page.getByRole("combobox", {name: "选择思考深度", exact: true}).click(); await page.getByRole("option", {name: "high", exact: true}).click(); + assert.equal((await effortSaved).status(), 200); + assert.equal(sessionRequests.length, beforeActions, "Settings never create a replacement Session"); + const afterSettings = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.equal(afterSettings.length, packetsBefore.length, "Saving a choice starts no thread, resume or model work"); await page.keyboard.press("Escape"); + const configuredAdmission = page.waitForResponse(response => response.url().endsWith(`/sessions/${sessionId}/turns`) && response.status() === 202); await composer.press("Enter"); - await menu.locator(".personal-model-current").filter({hasText: "trial-native-model"}).waitFor(); - assert.equal(sessionRequests.at(-1).agent_model_id, "trial-native-model"); - assert.equal(sessionRequests.at(-1).agent_reasoning_effort, "high"); - const packets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); - assert.ok(packets.some(row => row.method === "thread/start" && row.params.model === "trial-native-model" - && row.params.config.model_reasoning_effort === "high")); + const configuredResponse = await configuredAdmission; + const originalSend = configuredResponse.request().postDataJSON(); + const originalTurn = (await configuredResponse.json()).turn_id; await page.locator(".personal-message-pending").waitFor({state: "hidden"}); - const completedPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); - assert.ok(completedPackets.some(row => row.method === "turn/start" && row.params.effort === "high")); - // This configuration owner still creates a new Session; layout must not - // claim the separately requested same-Session configuration is implemented. + await menu.locator(".personal-model-current").filter({hasText: "trial-native-model"}).waitFor(); + let completedPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.ok(completedPackets.some(row => row.method === "thread/resume" && row.params.threadId === "durable-thread" + && row.params.model === "trial-native-model" && row.params.config.model_reasoning_effort === "high")); + assert.equal(completedPackets.filter(row => row.method === "thread/start").length, + packetsBefore.filter(row => row.method === "thread/start").length, "Model changes never fork the native thread"); + assert.ok(completedPackets.some(row => row.method === "turn/start" && row.params.model === "trial-native-model" && row.params.effort === "high")); + await page.reload({waitUntil: "networkidle"}); + await composer.waitFor(); + await menu.locator(".personal-model-current").filter({hasText: "trial-native-model"}).waitFor(); + const restoredSessions = (await (await page.request.get(`${url}../api/chat/sessions`)).json()).sessions; + assert.deepEqual(restoredSessions.map(row => row.session_id).sort(), sessionsBeforeSettings); + assert.equal(restoredSessions.find(row => row.session_id === sessionId).requested_agent_reasoning_effort, "high"); + const snapshotUrl = new URL(`/api/chat/sessions/${sessionId}`, url).href; + const beforeBusy = await (await page.request.get(snapshotUrl)).json(); + await composer.fill("wait for interrupt"); + const admitted = page.waitForResponse(response => response.url().endsWith(`/sessions/${sessionId}/turns`) && response.status() === 202); + await composer.press("Enter"); + const activeId = (await (await admitted).json()).turn_id; + const activeDeadline = Date.now() + 15_000; + let activeSnapshot; + do { + activeSnapshot = await (await page.request.get(snapshotUrl)).json(); + if (activeSnapshot.active_turn?.turn_id === activeId && activeSnapshot.active_turn?.status === "running") break; + await page.waitForTimeout(100); + } while (Date.now() < activeDeadline); + assert.equal(activeSnapshot.active_turn?.status, "running"); + await composer.fill("运行中调整设置,保留当前草稿。"); + const packetsWhileBusy = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + await menu.locator("summary").click(); + const busyChoice = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.status() === 200); + await page.getByRole("combobox", {name: "选择思考深度", exact: true}).click(); + await page.getByRole("option", {name: "low", exact: true}).click(); + const busySaved = (await (await busyChoice).json()).session; + assert.equal(busySaved.active_turn_id, activeId); + assert.equal(busySaved.agent_reasoning_effort, "high"); + assert.equal(busySaved.requested_agent_reasoning_effort, "low"); + assert.equal(await composer.inputValue(), "运行中调整设置,保留当前草稿。"); + const savedBusyPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.equal(savedBusyPackets.length, packetsWhileBusy.length, "Busy configuration saves never interrupt or resume the active native Turn"); + await capture(page, "composer-current-session-pending-desktop.png"); + await page.setViewportSize({width: 390, height: 844}); + await capture(page, "composer-current-session-pending-narrow.png"); + await page.setViewportSize({width: 1440, height: 900}); + await page.keyboard.press("Escape"); + const queued = await page.request.post(new URL(`/api/chat/sessions/${sessionId}/turns`, url).href, + {data: {client_turn_id: "next-settings-followup", delivery: "queue", message: "A queued follow-up uses the saved configuration."}}); + assert.equal(queued.status(), 202, await queued.text()); + const queuedId = (await queued.json()).turn_id; + assert.notEqual(queuedId, activeId); + const stillActive = await (await page.request.get(snapshotUrl)).json(); + assert.equal(stillActive.session.active_turn_id, activeId, "A queued follow-up does not replace the running Turn"); + const interrupted = await page.request.post(new URL(`/api/chat/sessions/${sessionId}/turns/${activeId}/interrupt`, url).href, {data: {}}); + assert.equal(interrupted.status(), 200, await interrupted.text()); + const queuedDeadline = Date.now() + 15_000; + let afterBusy; + do { + afterBusy = await (await page.request.get(snapshotUrl)).json(); + if (afterBusy.messages?.some(row => row.turn_id === queuedId && row.role === "agent")) break; + await page.waitForTimeout(100); + } while (Date.now() < queuedDeadline); + assert.ok(afterBusy.messages.some(row => row.turn_id === queuedId && row.role === "agent"), "The queued Turn produced its own final answer"); + assert.equal(afterBusy.session.agent_reasoning_effort, "low", JSON.stringify({ + status: afterBusy.session.status, active: afterBusy.session.active_turn_id, + requested: afterBusy.session.requested_agent_reasoning_effort, observed: afterBusy.session.agent_reasoning_effort, + native: (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)) + .filter(row => ["thread/resume", "turn/start"].includes(row.method)).map(row => ({method: row.method, model: row.params.model, effort: row.params.effort, config: row.params.config})), + })); + assert.equal(afterBusy.session.session_id, sessionId); + assert.deepEqual(afterBusy.messages.slice(0, beforeBusy.messages.length), beforeBusy.messages, "Earlier history survives model changes byte-for-byte"); + completedPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.equal(completedPackets.findLast(row => row.method === "turn/start").params.effort, "low"); + assert.equal(completedPackets.filter(row => row.method === "thread/start").length, + packetsBefore.filter(row => row.method === "thread/start").length, "Busy-to-queued adoption also retains the original thread"); + await page.reload({waitUntil: "networkidle"}); + await menu.locator("summary small").filter({hasText: "low"}).waitFor(); await menu.locator("summary").click(); - await page.getByText("新对话配置", {exact: true}).waitFor(); + await page.getByText("后续消息", {exact: true}).waitFor(); + const rejectionDepth = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.status() === 200); + await page.getByRole("combobox", {name: "选择思考深度", exact: true}).click(); + await page.getByRole("option", {name: "high", exact: true}).click(); + await rejectionDepth; + const rejectedChoiceSaved = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.request().method() === "POST"); await page.getByRole("combobox", {name: "选择模型", exact: true}).click(); await page.getByRole("option", {name: "Rejected model", exact: true}).click(); + assert.equal((await rejectedChoiceSaved).status(), 200); await page.keyboard.press("Escape"); + const beforeReplayPackets = await readFile(packetLog, "utf8"); + const originalReceipt = await page.request.post(new URL(`/api/chat/sessions/${sessionId}/turns`, url).href, {data: originalSend}); + assert.equal(originalReceipt.status(), 202, await originalReceipt.text()); + const replayBody = await originalReceipt.json(); + assert.equal(replayBody.turn_id, originalTurn); + assert.equal(replayBody.created, false, "A completed request keeps its receipt despite a later unsupported configuration"); + assert.equal(await readFile(packetLog, "utf8"), beforeReplayPackets, "Receipt recovery starts no native configuration or work"); + const invalidIdentity = await page.request.post(new URL(`/api/chat/sessions/${sessionId}/turns`, url).href, + {data: {client_turn_id: "invalid/identity", message: "Reject the request before any native preparation."}}); + assert.equal(invalidIdentity.status(), 400, await invalidIdentity.text()); + assert.equal(await readFile(packetLog, "utf8"), beforeReplayPackets, "Malformed request identity cannot prepare native configuration"); await composer.fill("配置被拒绝时保留这条消息。"); - const rejection = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions" && response.status() === 424); + const rejection = page.waitForResponse(response => response.url().endsWith(`/sessions/${sessionId}/turns`) && response.status() === 424); await composer.press("Enter"); - assert.equal((await rejection).status(), 424); + const rejectedResponse = await rejection; + assert.equal(rejectedResponse.status(), 424); + const rejectedBody = await rejectedResponse.json(); + assert.equal(rejectedBody.error_code, "unsupported_configuration"); + assert.equal(rejectedBody.delivery_state, "not_delivered"); await page.waitForFunction(() => document.querySelector("textarea")?.value === "配置被拒绝时保留这条消息。"); - await page.locator(".personal-message-pending").waitFor({state: "hidden"}); assert.equal(await composer.inputValue(), "配置被拒绝时保留这条消息。"); - assert.equal(await menu.locator(".personal-model-current").innerText(), "trial-native-model", - "A rejected selection cannot replace actual model readback"); + const refusedSession = (await (await page.request.get(`${url}../api/chat/sessions`)).json()).sessions.find(row => row.session_id === sessionId); + assert.equal(refusedSession.agent_model_id, "trial-native-model", "A refused native configuration cannot replace actual readback"); const refusedPackets = (await readFile(packetLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); assert.equal(refusedPackets.filter(row => row.method === "turn/start").length, - completedPackets.filter(row => row.method === "turn/start").length, - "Rejected Session configuration cannot dispatch or replay the message"); + completedPackets.filter(row => row.method === "turn/start").length, "Rejected configuration cannot dispatch or replay the message"); await capture(page, "composer-configuration-rejected.png"); + await menu.locator("summary").click(); + const repairedChoice = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.status() === 200); + await page.getByRole("combobox", {name: "选择模型", exact: true}).click(); + await page.getByRole("option", {name: "Trial native model", exact: true}).click(); + await repairedChoice; + await page.keyboard.press("Escape"); + const recoveredAdmission = page.waitForResponse(response => response.url().endsWith(`/sessions/${sessionId}/turns`) && response.status() === 202); + await composer.press("Enter"); + await recoveredAdmission; + await page.locator(".personal-message-pending").waitFor({state: "hidden"}); + const recoveredSessions = (await (await page.request.get(new URL("/api/chat/sessions", url).href)).json()).sessions; + assert.deepEqual(recoveredSessions.map(row => row.session_id).sort(), sessionsBeforeSettings, + "Correcting rejected settings never makes the next Send start a new Session"); console.log("workspace scope browser smoke ok; composer actions, actual native model/effort and rejection retained"); } finally { await browser?.close(); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 8ff412a6b4..33fcd85cc9 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -792,6 +792,11 @@ export async function createChatSession( } export type AgentModelOption = { value: string; label: string; reasoning_efforts?: string[] }; +export function configureChatSessionModel(sessionId: string, model: string, effort?: string) { + return requestJson<{ok: true; session: ChatSessionSummary}>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/model-settings`, { + method: "POST", body: JSON.stringify({agent_model_id: model, ...(effort ? {agent_reasoning_effort: effort} : {})}), + }); +} export type AgentModelDiscovery = { ok: true; schema_version: "loopx_chat_agent_model_options_v1"; @@ -825,6 +830,9 @@ export type ChatSessionSummary = { agent_model_id?: string | null; agent_reasoning_effort?: string | null; agent_service_tier?: string | null; + requested_agent_model_id?: string | null; + requested_agent_reasoning_effort?: string | null; + model_settings_supported?: boolean; adapter_kind: string; channel_id?: string; status: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-controls.ts b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-controls.ts index 35a012c699..7f0ec5ee19 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-controls.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-controls.ts @@ -2,18 +2,19 @@ import type { AgentModelDiscovery, ChatSessionSummary } from "../../data/chat"; /** A late Session response must not replace a pending owner selection. */ export function modelSelectionFromReadback( - readback: Pick, + readback: Pick, requested: { model?: string; effort?: string }, ) { - const sameModel = !requested.model || requested.model === readback.agent_model_id; + const model = readback.requested_agent_model_id ?? readback.agent_model_id; + const effort = readback.requested_agent_reasoning_effort ?? readback.agent_reasoning_effort; + const sameModel = !requested.model || requested.model === model; return { - model: sameModel ? readback.agent_model_id ?? "" : undefined, - effort: sameModel && (!requested.effort || requested.effort === readback.agent_reasoning_effort) - ? readback.agent_reasoning_effort ?? "" : undefined, + model: sameModel ? model ?? "" : undefined, + effort: sameModel && (!requested.effort || requested.effort === effort) ? effort ?? "" : undefined, }; } -/** Catalog choices describe the next binding; only Session readback is current. */ +/** Catalog choices are candidates; requested and observed Session settings stay distinct. */ export function chatModelControls( discovery: AgentModelDiscovery | null, selectedModel: string, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx index d50975e08b..529dfe1c4f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/chat-model-menu.tsx @@ -1,6 +1,7 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Clock3 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { discoverAgentModelOptions, type AgentModelDiscovery, type ChatSessionSummary } from "../../data/chat"; +import { ChatApiError, discoverAgentModelOptions, type AgentModelDiscovery, type ChatSessionSummary } from "../../data/chat"; +import { ContextHelp } from "./context-help"; import { chatModelControls } from "./chat-model-controls"; import { useWorkspaceI18n } from "./i18n"; import { WorkspaceSelect } from "./workspace-select"; @@ -8,7 +9,7 @@ import { WorkspaceSelect } from "./workspace-select"; /** Reported models stay visible; missing observations belong in the settings disclosure. */ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, readback, onModel, onEffort, onOpenDefaults }: { agentId: string; goalId?: string; ordinary: boolean; model?: string; effort?: string; - readback?: ChatSessionSummary; onModel?: (value: string) => void; onEffort?: (value: string) => void; + readback?: ChatSessionSummary; onModel?: (value: string) => void | Promise; onEffort?: (value: string) => void | Promise; onOpenDefaults?: () => void; }) { const { locale } = useWorkspaceI18n(); @@ -19,8 +20,24 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [retry, setRetry] = useState(0); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const retrySave = useRef<(() => void | Promise) | null>(null); + const saveAttempt = useRef(0); + const editable = ordinary && readback?.model_settings_supported !== false; useEffect(() => { - if (!open || !ordinary) return; + saveAttempt.current += 1; retrySave.current = null; setSaveError(null); setSaving(false); + }, [agentId, goalId, readback?.session_id]); + async function save(change: () => void | Promise) { + const attempt = ++saveAttempt.current; + retrySave.current = change; + setSaving(true); setSaveError(null); + try { await change(); } + catch (cause) { if (attempt === saveAttempt.current) setSaveError(cause instanceof Error ? cause : new Error(String(cause))); } + finally { if (attempt === saveAttempt.current) setSaving(false); } + } + useEffect(() => { + if (!open || !editable) return; const controller = new AbortController(); setDiscovery(null); setLoading(true); setError(null); void discoverAgentModelOptions(agentId, controller.signal, goalId, model) @@ -29,7 +46,7 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r setError(cause instanceof Error ? cause.message : (zh ? "配置读取失败" : "Settings unavailable")); setLoading(false); } }); return () => controller.abort(); - }, [open, ordinary, agentId, goalId, model, retry, zh]); + }, [open, editable, agentId, goalId, model, retry, zh]); useEffect(() => { const close = (event: PointerEvent | KeyboardEvent) => { if (!root.current?.open) return; @@ -48,22 +65,28 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r const observedSettings = controls.observedModel ? [controls.observedEffort, controls.observedTier ? (["priority", "fast"].includes(controls.observedTier) ? "Fast" : controls.observedTier) : null, ].filter(Boolean).join(" · ") : ""; + const nextModel = ordinary ? readback?.requested_agent_model_id : null; + const nextEffort = ordinary ? readback?.requested_agent_reasoning_effort : null; + const pending = Boolean(nextModel && (nextModel !== controls.observedModel || nextEffort && nextEffort !== controls.observedEffort)); + const summarySettings = pending ? nextEffort ?? "" : observedSettings; return
setOpen(event.currentTarget.open)}> - - {observed}{observedSettings ? {observedSettings} : null} + + {nextModel ?? observed}{summarySettings ? {summarySettings} : null}{pending ? : null}
- {zh ? "当前会话" : "Current conversation"} + {zh ? "运行时回读" : "Runtime readback"}

{controls.observedModel ? [controls.observedModel, controls.observedEffort ? `${zh ? "推理" : "Reasoning"} ${controls.observedEffort}` : null, controls.observedTier ? `${zh ? "速度" : "Speed"} ${["priority", "fast"].includes(controls.observedTier) ? "Fast" : controls.observedTier}` : null].filter(Boolean).join(" · ") : (zh ? "运行时尚未回传实际模型" : "Runtime has not reported the actual model")}

- {ordinary ? <> - {zh ? "新对话配置" : "New conversation settings"} -

{zh ? "用于下次新对话,保留原历史和活动回合。" : "Applies to a new conversation; existing history and active work are preserved."}

+ {ordinary && !editable ?

{zh ? "请在此会话的原宿主或专属设置中调整配置。" : "Change this conversation in its owning host or dedicated settings."}

: null} + {editable ? <> +
{zh ? "后续消息" : "Subsequent messages"} +

{zh ? "保存到当前会话,后续消息采用;当前回合和原历史不变。尚未建立会话时,用于首次发送。" : "Saved to this conversation for subsequent messages; active work and history stay unchanged. Before a conversation exists, used for its first message."}

+
{controls.modelOptions.length ? onModel?.(value)} + className="personal-agent-select personal-model-select" onChange={value => void save(() => onModel?.(value))} prefixLabel={zh ? "模型" : "Model"} value={model} options={[ ...(!model ? [{ disabled: true, label: zh ? "选择模型" : "Choose model", value: "" }] : []), ...(model && !controls.modelOptions.some(option => option.value === model) @@ -71,13 +94,20 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r ...controls.modelOptions.map(option => ({ label: option.value === model ? option.value : option.label, value: option.value })), ]}/> : null} {controls.reasoningOptions.length && onEffort ? void save(() => onEffort(value))} prefixLabel={zh ? "思考深度" : "Reasoning"} value={controls.effort} options={[ - { value: "", label: zh ? "沿用默认" : "Use default" }, + ...(!readback ? [{ value: "", label: zh ? "沿用默认" : "Use default" }] : []), ...(controls.effort && !controls.reasoningOptions.some(option => option.value === controls.effort) ? [{ value: controls.effort, label: `${controls.effort} · ${zh ? "当前不可用" : "Unavailable"}`, disabled: true }] : []), ...controls.reasoningOptions, ]}/> : null} +
+ {saving ?

{zh ? "正在保存…" : "Saving…"}

: null} + {saveError ?

{zh + ? (saveError instanceof ChatApiError && saveError.payload.error_code === "unsupported_configuration" + ? "当前会话不支持所选模型或思考深度,已保存配置未更改。" : "配置保存未确认,请重试;当前配置以运行时回读为准。") + : saveError.message}

{saveError.message}

+

: null} {discovery && controls.modelOptions.length > 0 && !controls.reasoningOptions.length && model ?

{zh ? "运行时未提供思考深度选项" : "Runtime provides no reasoning choices"}

: null} {discovery?.supported === false ?

{zh ? "执行器未提供可选模型与思考深度" : "Executor provides no model or reasoning choices"}

: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx b/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx index 4a9138fdaa..e0753df0dd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/composer-controls.tsx @@ -10,8 +10,8 @@ import { WorkspaceSelect } from "./workspace-select"; export function ComposerRuntimeControls({ agents, agentId, goalId, original, model, effort, readback, onAgent, onModel, onEffort, onDefaults }: { agents: WorkspaceAgentOption[]; agentId: string; goalId?: string; original: boolean; model?: string; effort?: string; readback?: ChatSessionSummary; - onAgent: (id: string) => void; onModel?: (id: string) => void; - onEffort?: (effort: string) => void; onDefaults?: () => void; + onAgent: (id: string) => void; onModel?: (id: string) => void | Promise; + onEffort?: (effort: string) => void | Promise; onDefaults?: () => void; }) { const { locale, t } = useWorkspaceI18n(); const statusOnly = agentId === "status-only"; 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 36d49babcf..4e6b334287 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 @@ -621,8 +621,8 @@ export type PersonalWorkspaceCallbacks = { onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string, settings?: LoopXModeSettings) => void; onSelectAgent?: (agentId: string) => void; - onSelectAgentModel?: (agentId: string, modelId: string) => void; - onSelectAgentReasoningEffort?: (agentId: string, effort: string) => void; + onSelectAgentModel?: (agentId: string, modelId: string) => void | Promise; + onSelectAgentReasoningEffort?: (agentId: string, effort: string) => void | Promise; 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 c595185b3f..765bf66d83 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 @@ -1115,6 +1115,13 @@ export function PersonalWorkspacePage({ ? conversationContextKey({ kind: "project", projectRef: selectedWorkspaceRef }) : selectedGoalId ?? "manager"; const composerDraftKey = conversationInputKey(conversationKey, selectedAgentId, managedWorkActive || Boolean(originalConversation)); + const [modelSettingsSaving, setModelSettingsSaving] = useState>({}); + async function saveModelSettings(change: () => void | Promise) { + const key = composerDraftKey; + setModelSettingsSaving(current => ({...current, [key]: true})); + try { await change(); } + finally { setModelSettingsSaving(current => { const next = {...current}; delete next[key]; return next; }); } + } const { composer, setComposer, restoreFailedSubmission, sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError, @@ -2343,6 +2350,7 @@ export function PersonalWorkspacePage({ } async function sendMessage(messageOverride?: string, intent: "send" | "steer" = "send") { + if (modelSettingsSaving[composerDraftKey]) return; const pendingImages = messageOverride ? [] : imageAttachments; const message = (messageOverride ?? composer).trim() || (pendingImages.length ? t("composer.imageAnalysisPrompt") : ""); if (!message || composerBlocked || workspaceUnavailable || (originalConversation && !originalConversation.session?.resumable) || (workspaceConversations?.managed && !workspaceConversations.managed.session?.resumable) || conversationHistoryState?.sendBlocked) return; @@ -2464,7 +2472,9 @@ export function PersonalWorkspacePage({ if (!messageOverride) { restoreFailedSubmission(message, pendingImages); } - const errorMessage = error instanceof Error ? error.message : t("feedback.sendGenericError"); + const errorMessage = error instanceof ChatApiError && error.payload.error_code === "unsupported_configuration" + ? (locale === "zh-CN" ? "请在模型菜单调整配置后重试。" : "Adjust the model menu and retry.") + : error instanceof Error ? error.message : t("feedback.sendGenericError"); setActionFeedback(t("feedback.sendFailed", { error: errorMessage })); } finally { setSending(false); @@ -3140,11 +3150,11 @@ export function PersonalWorkspacePage({ key={`${conversationKey}:${selectedAgentId}`} agents={agents} agentId={selectedAgentId} goalId={selectedGoal?.goalId} original={Boolean(originalConversation || managedWorkActive)} model={selectedAgentModelId} effort={selectedAgentReasoningEffort} readback={selectedAgentModelReadback} - onAgent={selectAgent} onModel={model => callbacks.onSelectAgentModel?.(selectedAgentId, model)} - onEffort={effort => callbacks.onSelectAgentReasoningEffort?.(selectedAgentId, effort)} + onAgent={selectAgent} onModel={model => saveModelSettings(() => callbacks.onSelectAgentModel?.(selectedAgentId, model))} + onEffort={effort => saveModelSettings(() => callbacks.onSelectAgentReasoningEffort?.(selectedAgentId, effort))} onDefaults={selectedGoal && !originalConversation ? () => openSettings({kind: "settings", tab: "conversation"}, true) : undefined} /> : null} - +
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 6672b1953d..05513f75a8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -2212,6 +2212,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-composer-runtime .personal-chat-model-menu > summary .personal-model-current { font-size: var(--text-caption); } .personal-composer-runtime .personal-chat-model-panel { top: auto; bottom: calc(100% + 12px); } .personal-composer-runtime .personal-chat-model-panel .personal-select-listbox { bottom: auto; } +.personal-model-next-heading { display: flex; align-items: center; gap: 6px; } +.personal-model-options { display: grid; gap: 8px; min-width: 0; margin: 0; padding: 0; border: 0; } +.personal-model-options > .personal-agent-select { width: 100%; max-width: none; } .personal-composer-actions { position: relative; flex: none; margin: 0; } .personal-channel-composer .personal-composer-actions { position: static; } .personal-composer-actions > summary { display: grid; place-items: center; width: 36px; height: 36px; min-height: 36px; padding: 0; border-radius: 6px; color: var(--pw-muted); cursor: pointer; list-style: none; } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index d8aa2e6f0d..2408019acd 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -104,6 +104,7 @@ import { } from "../features/personal-workspace/agent-family"; import { PersonalWorkspacePage } from "../features/personal-workspace/personal-workspace-page"; import { modelSelectionFromReadback } from "../features/personal-workspace/chat-model-controls"; +import { configureChatSessionModel } from "../data/chat"; import { visibleAgentMessage } from "../features/personal-workspace/answer-text"; import { useWorkspaceI18n, type WorkspaceTranslate } from "../features/personal-workspace/i18n"; import { @@ -1640,7 +1641,9 @@ function PersonalGoalHome({ function recordSessionAdmission(targetContextId: string, session: ChatSessionSummary) { const key = agentModelSelectionKey(targetContextId, session.agent_id); - setAgentModelReadbacks(current => ({...current, [key]: session})); + setAgentModelReadbacks(current => current[key]?.session_id === session.session_id + && Date.parse(current[key].updated_at) > Date.parse(session.updated_at) + ? current : {...current, [key]: session}); const selection = modelSelectionFromReadback(session, { model: requestedAgentModels.current[key], effort: requestedAgentEfforts.current[key] }); if (selection.model !== undefined) setSelectedAgentModels((current) => current[key] === selection.model ? current : { ...current, [key]: selection.model! }); @@ -2604,6 +2607,8 @@ function PersonalGoalHome({ sourceLabel: "LoopX Chat 本地后端", text: payloadError?.error_code === "server_restarted" ? t("feedback.turnRestarted") + : payloadError?.error_code === "unsupported_configuration" && payloadError.delivery_state === "not_delivered" + ? (locale === "zh-CN" ? "所选模型或思考深度未获运行时确认。消息未发送;请在模型菜单调整后重试。原会话和草稿已保留。" : "The runtime did not confirm the selected model or reasoning effort. No message was sent. Adjust the model menu and retry; this conversation and draft are retained.") : payloadError?.error_code === "resume_failed" ? `原 ${answerIdentityLabel(targetContextId, selectedRoute.label)} 会话无法恢复。本地历史已经保留,请在运行详情里选择“重试恢复”或“开始新 Session”。` : payloadError?.delivery_state === "not_delivered" && payloadError.turn_replay_safe === true && !submittedTurnId @@ -2739,30 +2744,42 @@ function PersonalGoalHome({ }); } - function chooseAgentModel(agentId: string, modelId: string) { + async function chooseAgentModel(agentId: string, modelId: string) { if (fixedSession) return; const selectionKey = agentModelSelectionKey(contextId, agentId); - if (selectedAgentModels[selectionKey] === modelId) return; - requestedAgentModels.current[selectionKey] = modelId; - delete requestedAgentEfforts.current[selectionKey]; + if (selectedAgentModels[selectionKey] === modelId + && requestedAgentModels.current[selectionKey] === modelId) return; setSelectedAgentEfforts((current) => { const next = { ...current }; delete next[selectionKey]; return next; }); setSelectedAgentModels((current) => current[selectionKey] === modelId ? current : { ...current, [selectionKey]: modelId }); const key = `${contextId}:${agentId}`; - sessionIds.current.delete(key); - newSessionRequired.current.add(key); - recordRuntimeBinding(contextId, null); + const sessionId = agentModelReadbacks[selectionKey]?.session_id ?? sessionIds.current.get(key); + if (sessionId) { + const result = await configureChatSessionModel(sessionId, modelId); + requestedAgentModels.current[selectionKey] = result.session.requested_agent_model_id ?? modelId; + const effort = result.session.requested_agent_reasoning_effort; + if (effort) requestedAgentEfforts.current[selectionKey] = effort; + recordSessionAdmission(contextId, result.session); + } else { + requestedAgentModels.current[selectionKey] = modelId; + delete requestedAgentEfforts.current[selectionKey]; + } } - function chooseAgentReasoningEffort(agentId: string, effort: string) { + async function chooseAgentReasoningEffort(agentId: string, effort: string) { if (fixedSession) return; const key = agentModelSelectionKey(contextId, agentId); - if (selectedAgentEfforts[key] === effort) return; - if (effort) requestedAgentEfforts.current[key] = effort; - else delete requestedAgentEfforts.current[key]; + if (selectedAgentEfforts[key] === effort && requestedAgentEfforts.current[key] === effort) return; setSelectedAgentEfforts((current) => ({ ...current, [key]: effort })); - sessionIds.current.delete(key); - newSessionRequired.current.add(key); - recordRuntimeBinding(contextId, null); + const sessionId = agentModelReadbacks[key]?.session_id ?? sessionIds.current.get(key); + if (sessionId) { + const model = selectedAgentModels[key] ?? agentModelReadbacks[key]?.requested_agent_model_id ?? agentModelReadbacks[key]?.agent_model_id; + if (!model) throw new Error("Choose a model before changing this conversation's reasoning effort."); + const result = await configureChatSessionModel(sessionId, model, effort); + requestedAgentModels.current[key] = result.session.requested_agent_model_id ?? model; + if (result.session.requested_agent_reasoning_effort) requestedAgentEfforts.current[key] = result.session.requested_agent_reasoning_effort; + recordSessionAdmission(contextId, result.session); + } else if (effort) requestedAgentEfforts.current[key] = effort; + else delete requestedAgentEfforts.current[key]; } function openGoalChat(goalId: string) { diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 762413aad8..824832168c 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -130,6 +130,25 @@ messages are excluded. Listening is neither a delivery receipt nor task acceptance. Native binding/composition/HTTP regressions qualify the source read model, while each provider's installed liveness and result return remain separate. +### Model choices in the current ordinary conversation + +The ordinary Codex composer saves an offered model and supported reasoning effort +on its exact existing Session. Choosing performs no Turn, thread creation, history +replay or interruption. The active Turn retains its actual configuration; the +saved subsequent choice and native runtime readback stay distinct in the menu. +Between Turns, the existing resume owner must confirm that choice on the same +native thread before dispatch. Rejection before a new message's admission keeps +its draft; a queued request keeps the existing queue's failure/recovery owner. +Reloading retains the Session and its saved choice; other Sessions and machine +defaults are unaffected. Save failure offers retry without claiming success. + +Host-owned, steward, registered-Agent, bound-audience, native Goal and legacy +migration Sessions retain their existing settings owner. Unsupported providers +cannot silently start a replacement conversation. Availability and admission use +one shared read model. Production HTTP/store and synthetic native-protocol tests +qualify settings, history, busy/queued adoption and refusal. They do not certify +live model quality, installed native input or cross-channel synchronization. + ### Continue an original private conversation on the local App Settings → Lark offers **Continue original conversation** on an existing diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index 30cc8ee7cd..2bb0d2117c 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -91,6 +91,20 @@ Lark listener 观测。该本地 hook 只读取缓存,不发起网络请求。 异常原文。监听就绪不等于送达回执或任务验收。原生 binding、宿主组合和 HTTP 回归 验证源码读模型;各 provider 的安装态监听及结果回传仍分别验收。 +### 当前普通会话的模型选择 + +普通 Codex 输入框将运行时提供的模型与支持的思考深度保存到确切已有 Session。 +选择不创建 Turn 或线程,不重放历史、不中断工作。活动回合保留实际配置;菜单区分 +已保存的后续选择与原生运行时回读。回合之间沿既有恢复 owner 在同一原生线程确认 +选择,然后才发送。新消息受理前被拒绝时保留草稿;排队请求仍由原队列处理失败与恢复。 +刷新保留 Session 和已保存选择,不改变其他会话或本机默认设置;保存失败可重试, +不能显示成功。 + +外部宿主、管家、注册 Agent、绑定受众、原生 Goal 和需要迁移的旧会话仍由原设置 +入口负责。不支持的 provider 不能悄悄新建替代对话。可用性与准入复用同一读模型。 +真实 HTTP/存储配合合成原生协议验证配置、历史、运行中/排队采用与拒绝;这些证据 +不代表真实模型质量、已安装原生输入或跨渠道同步已经通过验收。 + ### 在本机 App 继续原私聊会话 设置 → Lark 在已有本人私聊连接上提供 **继续原会话**。发现过程使用 Core 已核验 diff --git a/examples/personal-workspace-browser/agent-model-selection.mjs b/examples/personal-workspace-browser/agent-model-selection.mjs index 4a8031fb67..decbbb35d3 100644 --- a/examples/personal-workspace-browser/agent-model-selection.mjs +++ b/examples/personal-workspace-browser/agent-model-selection.mjs @@ -1,12 +1,15 @@ 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. +// Compiled UI with synthetic HTTP Session storage; native adoption is separately +// checked against the production HTTP/store and fake app-server protocol. export const agentModelSelectionScenario = { id: "agent-model-selection", async run({ browser, collectCoverage, url }) { let createdCount = 0; const requests = []; + const settingsRequests = []; + let failSave = false; let discoveryCount = 0; const managerBinding = { schema_version: "manager_channel_binding_v0", executor_endpoint: "codex", @@ -38,6 +41,7 @@ export const agentModelSelectionScenario = { const session = existing ?? { session_id: `model-session-${++createdCount}`, goal_id: body.goal_id || "loopx-manager", agent_id: body.agent_id ?? "codex", agent_model_id: body.agent_model_id ?? "runtime-default", agent_reasoning_effort: body.agent_reasoning_effort ?? "high", agent_service_tier: "priority", adapter_kind: "codex_app_server", channel_id: channel, + requested_agent_model_id: body.agent_model_id ?? null, requested_agent_reasoning_effort: body.agent_reasoning_effort ?? null, model_settings_supported: channel !== "manager", 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", }; @@ -47,6 +51,28 @@ export const agentModelSelectionScenario = { ok: true, session_id: session.session_id, session, resumed: Boolean(existing), agent_id: session.agent_id, } }); }); + await page.route("**/api/chat/sessions/*/model-settings", async route => { + const body = route.request().postDataJSON(); + const sid = new URL(route.request().url()).pathname.split("/")[4]; + settingsRequests.push({sessionId: sid, ...body}); + if (failSave) return route.fulfill({status: 503, json: {ok: false, error: "Settings storage unavailable"}}); + const previous = page.__loopxRuntime.sessions.get(sid); + const session = {...previous, requested_agent_model_id: body.agent_model_id, + requested_agent_reasoning_effort: body.agent_reasoning_effort ?? previous.requested_agent_reasoning_effort ?? previous.agent_reasoning_effort, + updated_at: "2026-08-13T01:00:02Z"}; + page.__loopxRuntime.sessions.set(sid, session); + return route.fulfill({json: {ok: true, session}}); + }); + await page.route("**/api/chat/sessions/*/turns", async route => { + if (route.request().method() === "POST") { + const sid = new URL(route.request().url()).pathname.split("/")[4]; + const session = page.__loopxRuntime.sessions.get(sid); + if (session) page.__loopxRuntime.sessions.set(sid, {...session, + agent_model_id: session.requested_agent_model_id ?? session.agent_model_id, + agent_reasoning_effort: session.requested_agent_reasoning_effort ?? session.agent_reasoning_effort}); + } + return route.fallback(); + }); }, }); const { page, api } = context; @@ -155,7 +181,7 @@ export const agentModelSelectionScenario = { await page.getByRole("combobox", { name: "选择思考深度" }).click(); await page.getByRole("option", { name: "low", exact: true }).click(); const original = await send("解释模型选择如何保持当前对话。"); - assert.equal(requests.at(-1).agent_reasoning_effort, "low", "An explicit effort is included in the native Session request"); + assert.equal(settingsRequests.at(-1).agent_reasoning_effort, "low", "An explicit effort is saved to the existing Session"); assert.match(await menu.locator("summary small").innerText(), /low/, "Current effort comes from Session readback"); await menu.locator(".personal-model-current").filter({ hasText: "safe" }).waitFor(); const before = createdCount; @@ -166,11 +192,11 @@ export const agentModelSelectionScenario = { await page.getByRole("combobox", { name: "选择思考深度" }).click(); await page.getByRole("option", { name: "high", exact: true }).click(); const changed = await send("使用新模型继续分析。"); - assert.notEqual(changed, original); - assert.equal(createdCount, before + 1, "a real model change creates exactly one distinct Session"); + assert.equal(changed, original); + assert.equal(createdCount, before, "A model change retains exactly the same 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"); - assert.equal(requests.at(-1).agent_model_id, "vendor/model fast", "an explicit choice uses the exact model identifier"); + assert.equal(settingsRequests.at(-1).agent_model_id, "vendor/model fast", "an explicit choice uses the exact model identifier"); await page.setViewportSize({width: 390, height: 844}); await openSettings(); const inputBounds = await page.locator(".personal-channel-composer").boundingBox(); @@ -185,7 +211,25 @@ export const agentModelSelectionScenario = { assert.match(await readback.innerText(), /high · Fast/); assert.ok((await readback.boundingBox()).height < 30, "speed and reasoning do not wrap into a vertical column"); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); - 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." }; + await page.keyboard.press("Escape"); + await draft.fill("保存失败也不能丢掉草稿。"); + await openSettings(); + failSave = true; + await page.getByRole("combobox", {name: "选择思考深度"}).click(); + await page.getByRole("option", {name: "low", exact: true}).click(); + await page.getByRole("button", {name: "重试保存", exact: true}).waitFor(); + assert.equal(await draft.inputValue(), "保存失败也不能丢掉草稿。"); + assert.equal(page.__loopxRuntime.sessions.get(original).requested_agent_reasoning_effort, "high"); + failSave = false; + const retried = page.waitForResponse(response => response.url().endsWith("/model-settings") && response.status() === 200); + await page.getByRole("button", {name: "重试保存", exact: true}).click(); + await retried; + assert.equal(page.__loopxRuntime.sessions.get(original).requested_agent_reasoning_effort, "low"); + assert.equal(createdCount, before); + await page.reload({waitUntil: "networkidle"}); + await menu.locator("summary small").filter({hasText: "low"}).waitFor(); + assert.equal(createdCount, before, "Reopening retains the same Session and its saved subsequent choice"); + return { coverageEntries: context.coverageEntries, note: "Current-Session model/depth changes, failure retry and refresh preserve identity and draft through the compiled UI and synthetic HTTP store; native protocol adoption is qualified separately." }; } finally { await context.close(); } diff --git a/examples/personal-workspace-browser/evidence/current-session-settings/composer-configuration-rejected.png b/examples/personal-workspace-browser/evidence/current-session-settings/composer-configuration-rejected.png new file mode 100644 index 0000000000..65f79d4810 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/current-session-settings/composer-configuration-rejected.png differ diff --git a/examples/personal-workspace-browser/evidence/current-session-settings/composer-current-session-pending-desktop.png b/examples/personal-workspace-browser/evidence/current-session-settings/composer-current-session-pending-desktop.png new file mode 100644 index 0000000000..8c4f2880bf Binary files /dev/null and b/examples/personal-workspace-browser/evidence/current-session-settings/composer-current-session-pending-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/current-session-settings/composer-current-session-pending-narrow.png b/examples/personal-workspace-browser/evidence/current-session-settings/composer-current-session-pending-narrow.png new file mode 100644 index 0000000000..5ef88f2f31 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/current-session-settings/composer-current-session-pending-narrow.png differ diff --git a/loopx/capabilities/native_chat/model_settings.py b/loopx/capabilities/native_chat/model_settings.py index 95f86949e5..69e1a8238c 100644 --- a/loopx/capabilities/native_chat/model_settings.py +++ b/loopx/capabilities/native_chat/model_settings.py @@ -4,13 +4,42 @@ """ from __future__ import annotations -from typing import Any +from pathlib import Path +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ...chat_runtime import ChatRuntimeController, ChatRuntimeAdapter _AGENT_MODEL_ID_MAX_LENGTH = 160 MODEL_SELECTION_UNSET = object() +def session_model_settings_supported(session: dict[str, Any]) -> bool: + """Project the same current-Session boundary used by the save operation. + + Host-owned, native Goal and legacy migration Sessions keep their existing + configuration owner. A model choice must never trigger their thread replay. + Imports are local because the Session store also uses these normalizers. + """ + from ...chat_coordination import PROJECT_CONTEXT_VERSION + from ...chat_manager import is_manager_channel + from ...chat_store import CHAT_SESSION_MODE_MANAGED + + return bool( + session.get("agent_id") == "codex" + and session.get("session_mode") == CHAT_SESSION_MODE_MANAGED + and session.get("upstream_mode") == "chat" + and session.get("status") in {"ready", "busy", "resuming", "resume_failed"} + and session.get("managed_agent_id") is None + and not is_manager_channel(session.get("channel_id")) + and session.get("steward_context") is None + and (session.get("project_context") or {}).get("audience") not in {"bound_owner", "bound_group"} + and (session.get("goal_id") is None + or session.get("coordination_context_version") == PROJECT_CONTEXT_VERSION) + ) + + def normalize_agent_model_id(value: Any) -> str: """Keep a provider model value without applying file-name rules. @@ -48,3 +77,119 @@ def normalize_session_model_settings( if requested_effort is not MODEL_SELECTION_UNSET: fields["requested_agent_reasoning_effort"] = normalize_agent_reasoning_effort(requested_effort) if requested_effort is not None else None return fields + + +def inspect_agent_model_options(controller: ChatRuntimeController, agent_id: str, *, work_dir: Path, goal_id: str | None = None, + agent_model_id: str | None = None) -> 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. + """ + from ...chat_agent import CodexChatAgentError, agent_endpoint_error + from ... import chat_providers as providers + from .machine_defaults import codex_profile_home, load_codex_chat_defaults + capability = next((row for row in controller.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": + defaults = load_codex_chat_defaults(controller.coordination_runtime_root) if goal_id else {} + home = codex_profile_home(controller.codex_home, defaults) + models = providers.codex_model_options(controller.codex_bin, codex_home=home) + if models is None: + raise CodexChatAgentError("The selected Codex account did not return model choices. Check Chat account settings, then retry; existing conversations are unchanged.", + error_code="provider_unavailable", gate=None) + return {"agent_id": agent_id, "model": models, "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 = controller._start_adapter( + agent_id=agent_id, + work_dir=work_dir, + goal_id=None, + objective="", + agent_config={"model": agent_model_id} if agent_model_id else None, + ) + try: + return {"agent_id": agent_id, "model": adapter.model_options(), + "reasoning_effort": adapter.reasoning_options(), "supported": True} + finally: + adapter.close_session() + + +def configure_session_model(controller: ChatRuntimeController, session_id: str, *, model: str, + effort: str | None = None) -> dict[str, Any]: + """Save choices for this ordinary Session's next Turn, never replace it. + + The active Turn keeps its adapter. Between Turns the existing resume + owner confirms the requested settings on the same upstream thread. + """ + from ...chat_agent import CodexChatAgentError + from ... import chat_providers as providers + from . import codex_context + model = normalize_agent_model_id(model) + if effort is not None: + effort = normalize_agent_reasoning_effort(effort) + with controller._session_adapter_lock(session_id): + session = controller.store.load_session(session_id) + if session is None or session.get("status") == "closed": + raise KeyError("chat session was not found") + if not session_model_settings_supported(session): + raise CodexChatAgentError("Change this conversation's configuration in its owning host or settings.", + error_code="unsupported_configuration", gate=None) + if session.get("project_context") is not None: + controller.project_contexts.session_context(session) + home = codex_context.checked_session_home(controller.codex_home, session) + catalog = providers.codex_model_options(controller.codex_bin, codex_home=home) + if catalog is None: + raise CodexChatAgentError("The selected Codex account did not return model choices; retry before changing settings.", + error_code="provider_unavailable", gate=None) + option = next((row for row in (catalog or {}).get("options", []) if row["value"] == model), None) + if option is None: + raise CodexChatAgentError("The selected account did not offer this model; the saved configuration is unchanged.", + error_code="unsupported_configuration", gate=None) + selected_effort = effort or session.get("requested_agent_reasoning_effort") or session.get("agent_reasoning_effort") + if selected_effort and selected_effort not in option.get("reasoning_efforts", []): + raise CodexChatAgentError("Choose a supported reasoning effort for this model; the saved configuration is unchanged.", + error_code="unsupported_configuration", gate=None) + if session.get("project_context") is not None: + controller.project_contexts.session_context(session) + return controller.store.update_session(session_id, expected_statuses={"ready", "busy", "resuming", "resume_failed"}, requested_agent_model_id=model, + requested_agent_reasoning_effort=selected_effort) + + +def observed_model_settings(adapter: ChatRuntimeAdapter, requested_effort: str | None = None, + requested_model: str | None = None) -> dict[str, Any]: + from ...chat_acp import ACPStdioAdapter + from .codex_adapter import CodexAppServerAdapter + from ...chat_agent import CodexChatAgentError + settings = adapter.model_settings if isinstance(adapter, (ACPStdioAdapter, CodexAppServerAdapter)) else {} + if requested_model and isinstance(adapter, (ACPStdioAdapter, CodexAppServerAdapter)) and adapter.current_model_id != requested_model: + raise CodexChatAgentError("The Agent did not confirm the requested model; no message was sent.", + error_code="unsupported_configuration", gate=None) + if requested_effort and settings.get("agent_reasoning_effort") != requested_effort: + raise CodexChatAgentError( + "The Agent did not confirm the requested reasoning effort; no message was sent.", + error_code="unsupported_configuration", gate=None, + ) + return settings + + +def session_model_settings_pending(session: dict[str, Any]) -> bool: + return bool(session_model_settings_supported(session) and any( + session.get(requested) is not None and session[requested] != session.get(observed) + for requested, observed in (("requested_agent_model_id", "agent_model_id"), + ("requested_agent_reasoning_effort", "agent_reasoning_effort")))) diff --git a/loopx/capabilities/native_chat/model_settings_api.py b/loopx/capabilities/native_chat/model_settings_api.py new file mode 100644 index 0000000000..b497b4acf0 --- /dev/null +++ b/loopx/capabilities/native_chat/model_settings_api.py @@ -0,0 +1,62 @@ +"""Loopback model discovery and current-Session configuration routes.""" +from __future__ import annotations + +from pathlib import Path +from ...chat_agent import CodexChatAgentError +from .model_settings import normalize_agent_model_id + + +class ChatModelSettingsRequestMixin: + def _agent_model_options(self) -> None: + from ...chat_server import _compact_text + try: + body = self._read_json() + if "agent_id" not in body or set(body) - {"agent_id", "goal_id", "agent_model_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") + goal_id = _compact_text(body.get("goal_id"), limit=160) or None + if goal_id: + self._registry_and_goal(goal_id) + requested_model = normalize_agent_model_id(body["agent_model_id"]) if body.get("agent_model_id") is not None else None + # 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(), **({"goal_id": goal_id} if goal_id else {}), + **({"agent_model_id": requested_model} if requested_model else {}), + ) + 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 _configure_session_model(self, session_id: str) -> None: + try: + body = self._read_json() + if "agent_model_id" not in body or set(body) - {"agent_model_id", "agent_reasoning_effort"}: + raise ValueError("model settings require agent_model_id and optional agent_reasoning_effort") + session = self.server.chat_store.load_session(session_id) + if session is None: + raise KeyError("chat session was not found") + self._session_context(session) + updated = self.server.runtime_controller.configure_session_model(session_id, + model=body["agent_model_id"], effort=body.get("agent_reasoning_effort")) + except KeyError: + self._send_error("chat session was not found", status=404) + return + except CodexChatAgentError as exc: + self._send_error(str(exc), status=424, error_code=exc.error_code, gate=exc.gate) + return + except (ValueError, TypeError) as exc: + self._send_error(str(exc)) + return + except OSError: + self._send_error("Conversation settings could not be saved; read the current settings and retry.", status=503) + return + self._send_json({"ok": True, "session": self.server.chat_store.public_session(updated)}) diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 11865c4d9d..8c3eca240f 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -1032,15 +1032,17 @@ def start( "Codex did not apply the requested conversation provider." ) if model and thread_result.get("model") not in {None, model}: - raise session._runtime_error( - "Codex did not apply the requested conversation model." + raise CodexChatAgentError( + "Codex did not apply the requested conversation model.", + error_code="unsupported_configuration", gate=None, ) if reasoning_effort and thread_result.get("reasoningEffort") not in { None, reasoning_effort, }: - raise session._runtime_error( - "Codex did not apply the requested conversation reasoning effort." + raise CodexChatAgentError( + "Codex did not apply the requested conversation reasoning effort.", + error_code="unsupported_configuration", gate=None, ) session.model = thread_result.get("model") or model session.resolved_model = thread_result.get("model") diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 3738c3301f..bd938d6f74 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -62,11 +62,15 @@ CHAT_SESSION_MODE_MANAGED, TERMINAL_TURN_STATES, ChatSessionStore, + _opaque_id, normalize_agent_model_id, utc_now, ) -from .chat_providers import ClaudeCodeAdapter, codex_model_options, direct_model_from_environment -from .capabilities.native_chat.model_settings import normalize_agent_reasoning_effort +from .chat_providers import ClaudeCodeAdapter, direct_model_from_environment +from .capabilities.native_chat.model_settings import ( + normalize_agent_reasoning_effort, configure_session_model, inspect_agent_model_options, + observed_model_settings, session_model_settings_pending, +) from .attached_session import ( ManagedSessionGoalCommit, enqueue_attached_agent_turn, @@ -110,6 +114,12 @@ class ChatTurnAcceptanceUnavailableError(Exception): """The durable acceptance attempt can be retried with the same request.""" +class ChatSessionConfigurationRejectedError(CodexChatAgentError): + """Configuration preparation failed before this request's Turn admission.""" + + + + class ChatRuntimeAdapter(Protocol): @property def upstream_thread_id(self) -> str: ... @@ -303,64 +313,12 @@ def capabilities(self) -> list[dict[str, Any]]: def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path, goal_id: str | None = None, agent_model_id: str | None = None) -> dict[str, Any]: - """Read model choices from one selected, available ACP endpoint. + return inspect_agent_model_options(self, agent_id, work_dir=work_dir, goal_id=goal_id, agent_model_id=agent_model_id) - 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 agent_id == "codex": - defaults = load_codex_chat_defaults(self.coordination_runtime_root) if goal_id else {} - home = codex_profile_home(self.codex_home, defaults) - models = codex_model_options(self.codex_bin, codex_home=home) - if models is None: - raise CodexChatAgentError("The selected Codex account did not return model choices. Check Chat account settings, then retry; existing conversations are unchanged.", - error_code="provider_unavailable", gate=None) - return {"agent_id": agent_id, "model": models, "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( - agent_id=agent_id, - work_dir=work_dir, - goal_id=None, - objective="", - agent_config={"model": agent_model_id} if agent_model_id else None, - ) - try: - return {"agent_id": agent_id, "model": adapter.model_options(), - "reasoning_effort": adapter.reasoning_options(), "supported": True} - finally: - adapter.close_session() + def configure_session_model(self, session_id: str, *, model: str, effort: str | None = None) -> dict[str, Any]: + return configure_session_model(self, session_id, model=model, effort=effort) - @staticmethod - def _observed_model_settings(adapter: ChatRuntimeAdapter, requested_effort: str | None = None, - requested_model: str | None = None) -> dict[str, Any]: - settings = adapter.model_settings if isinstance(adapter, (ACPStdioAdapter, CodexAppServerAdapter)) else {} - if requested_model and isinstance(adapter, (ACPStdioAdapter, CodexAppServerAdapter)) and adapter.current_model_id != requested_model: - raise CodexChatAgentError("The Agent did not confirm the requested model; no message was sent.", - error_code="unsupported_configuration", gate=None) - if requested_effort and settings.get("agent_reasoning_effort") != requested_effort: - raise CodexChatAgentError( - "The Agent did not confirm the requested reasoning effort; no message was sent.", - error_code="unsupported_configuration", gate=None, - ) - return settings + _observed_model_settings = staticmethod(observed_model_settings) @staticmethod def _session_objective( @@ -829,6 +787,11 @@ def _ensure_adapter_locked( active = self.store.load_turn(session_id, str(active_id)) if active_id else None if accepted_turn_id is None and active is not None and active.get("status") == "queued": accepted_turn_id = str(active["turn_id"]) + settings_changed = bool( + session_model_settings_pending(session) + and (not active or active.get("status") in TERMINAL_TURN_STATES + or active.get("status") == "queued" and active_id == accepted_turn_id) + ) reusable: ChatRuntimeAdapter | None = None # An owner-edited model takes effect between Turns. Keep a running Turn # and its binding intact; accepted queued work has not started upstream. @@ -869,6 +832,7 @@ def _ensure_adapter_locked( and not recovery.get("quota_attempt") and (isinstance(current, CodexAppServerAdapter) and current.session.read_only_review) == bool(recovery.get("read_only_review")) and model_allocation is None + and not settings_changed ): reusable = current elif current is not None: @@ -991,7 +955,9 @@ def _ensure_adapter_locked( ) try: # Recovery reads back the provider-restored configuration. - observed_settings = self._observed_model_settings(adapter) + observed_settings = self._observed_model_settings(adapter, + session.get("requested_agent_reasoning_effort") if settings_changed else None, + session.get("requested_agent_model_id") if settings_changed else None) if session.get("upstream_mode") == CODEX_GOAL_CHAT_MODE: if not isinstance(adapter, CodexAppServerAdapter): raise CodexChatAgentError("Native Goal continuation requires a Codex adapter.", error_code="native_goal_adapter_mismatch", gate=None) @@ -1000,13 +966,16 @@ def _ensure_adapter_locked( adapter.close_session() raise except Exception as exc: + configuration_error = isinstance(exc, CodexChatAgentError) and exc.error_code == "unsupported_configuration" with commit(): self.store.update_session( session_id, - status="busy" if accepted_turn_id is not None else "resume_failed", + status="busy" if accepted_turn_id is not None else "ready" if settings_changed else "resume_failed", active_turn_id=accepted_turn_id, - last_error_code="resume_failed", + last_error_code="unsupported_configuration" if settings_changed and configuration_error else "resume_failed", ) + if settings_changed and configuration_error: + raise gate = exc.gate if isinstance(exc, CodexChatAgentError) else None raise CodexChatAgentError( "The previous Agent conversation could not be restored.", @@ -1110,6 +1079,22 @@ def submit_turn( raise KeyError("chat session was not found") require_managed_agent_session(self, session, work_dir) codex_context.checked_session_home(self.codex_home, session) + active = self.store.load_turn(session_id, str(session["active_turn_id"])) if session.get("active_turn_id") else None + if (session_model_settings_pending(session) + and (active is None or active.get("status") in TERMINAL_TURN_STATES) + and self.store.turn_for_client(session_id, _opaque_id(client_turn_id, field="client_turn_id")) is None): + # Existing ingress identity belongs to the acceptance/replay + # owner, even if later settings cannot start a new Turn. + try: + self._ensure_adapter_locked(session, work_dir=work_dir, objective=objective, goal_commit=commit) + except CodexChatAgentError as exc: + raise ChatSessionConfigurationRejectedError(str(exc), error_code=exc.error_code, gate=exc.gate) from exc + except (KeyError, ValueError, OSError) as exc: + raise ChatSessionConfigurationRejectedError( + "The conversation configuration could not be prepared; no message was sent.", + error_code="resume_failed", gate=None, + ) from exc + session = self.store.load_session(session_id) or session try: with commit(): if loopx_execution and session.get("loopx_tools") is not True: diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 62b21ebbea..4687d7e606 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -41,11 +41,13 @@ add_goal_subagent_capability, add_goal_subagent_routes, ) +from .capabilities.native_chat.model_settings_api import ChatModelSettingsRequestMixin from .chat_status_api import ChatStatusRequestMixin from .chat_runtime import ( STEERING_NOT_DELIVERED_CODES, ChatRuntimeController, ChatTurnAcceptanceUnavailableError, + ChatSessionConfigurationRejectedError, ) from .chat_manager import ( MANAGER_AGENT_GOAL_ID, MANAGER_AGENT_OBJECTIVE, is_manager_channel, @@ -496,6 +498,7 @@ def server_close(self) -> None: class ChatRequestHandler( + ChatModelSettingsRequestMixin, GoalSourceRequestMixin, GoalAcceptanceRequestMixin, ZCodeGoalRequestMixin, @@ -723,32 +726,6 @@ def _create_session(self) -> None: status=200 if resumed else 201, ) - def _agent_model_options(self) -> None: - try: - body = self._read_json() - if "agent_id" not in body or set(body) - {"agent_id", "goal_id", "agent_model_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") - goal_id = _compact_text(body.get("goal_id"), limit=160) or None - if goal_id: - self._registry_and_goal(goal_id) - requested_model = normalize_agent_model_id(body["agent_model_id"]) if body.get("agent_model_id") is not None else None - # 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(), **({"goal_id": goal_id} if goal_id else {}), - **({"agent_model_id": requested_model} if requested_model else {}), - ) - 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.""" @@ -889,6 +866,11 @@ def _session_turn(self, session_id: str) -> None: ) return response = completed.get("response") + except ChatSessionConfigurationRejectedError as exc: + self._send_error(str(exc), status=424, + error_code="runtime_error" if exc.error_code == "resume_failed" else exc.error_code, + delivery_state="not_delivered", turn_replay_safe=True) + return except ChatTurnAcceptanceUnavailableError: self._send_error( "Chat turn acceptance is temporarily unavailable.", @@ -1143,6 +1125,7 @@ def _interrupt_turn(self, session_id: str, turn_id: str) -> None: } ) + def _resume_session(self, session_id: str) -> None: try: session = self.server.chat_store.load_session(session_id) @@ -1747,6 +1730,8 @@ def do_POST(self) -> None: return handle_loopx_request(self, session_action_parts[3], apply=True) if len(session_action_parts) == 5 and session_action_parts[:3] == ["api", "chat", "sessions"] and session_action_parts[4] == "resume": return self._resume_session(session_action_parts[3]) + if len(session_action_parts) == 5 and session_action_parts[:3] == ["api", "chat", "sessions"] and session_action_parts[4] == "model-settings": + return self._configure_session_model(session_action_parts[3]) action_parts = path.strip("/").split("/") if len(action_parts) == 4 and action_parts[:2] == ["api", "actions"] and action_parts[3] in {"apply", "cancel", "regenerate", "reject", "defer"}: if action_parts[3] == "apply": diff --git a/loopx/chat_store.py b/loopx/chat_store.py index b9268ed372..42a81fdf5a 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -23,6 +23,7 @@ ) from .capabilities.native_chat.model_settings import ( MODEL_SELECTION_UNSET as _MODEL_SELECTION_UNSET, normalize_agent_model_id, normalize_session_model_settings, + session_model_settings_supported, ) from .chat_event_cache import ChatEventCache from .chat_ingress import ( @@ -434,7 +435,7 @@ def reserve_loopx_outcome_request( _atomic_write_json(path, payload) return payload - def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: + def update_session(self, session_id: str, *, expected_statuses: set[str] | None = None, **changes: Any) -> dict[str, Any]: path = self._session_path(session_id) with self._session_lock(session_id): with exclusive_file_lock( @@ -445,6 +446,8 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: payload = self.load_session(session_id) if payload is None: raise KeyError("chat session was not found") + if expected_statuses is not None and payload.get("status") not in expected_statuses: + raise KeyError("chat session is no longer open") if payload.get("managed_agent_id") is not None and "goal_id" in changes and changes["goal_id"] != payload["goal_id"]: raise ValueError("a registered Agent Session cannot change its original Goal") allowed = { @@ -473,6 +476,7 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: "manager_executor_allocation", "agent_model_id", "agent_reasoning_effort", "agent_service_tier", + "requested_agent_model_id", "requested_agent_reasoning_effort", "goal_id", } unknown = set(changes) - allowed @@ -506,6 +510,9 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: changes["upstream_thread_id"] = _upstream_id(changes["upstream_thread_id"]) if "agent_model_id" in changes: changes["agent_model_id"] = normalize_agent_model_id(changes["agent_model_id"]) if changes["agent_model_id"] else None + for field in ("requested_agent_model_id", "requested_agent_reasoning_effort"): + if field in changes: + changes[field] = normalize_agent_model_id(changes[field]) if changes[field] is not None else None for key in ("agent_reasoning_effort", "agent_service_tier"): if key in changes: changes[key] = normalize_agent_model_id(changes[key]) if changes[key] else None @@ -1913,6 +1920,7 @@ def public_session(self, payload: dict[str, Any]) -> dict[str, Any]: "session_id", "goal_id", "agent_id", "adapter_kind", "status", "agent_model_id", "agent_reasoning_effort", "agent_service_tier", + "requested_agent_model_id", "requested_agent_reasoning_effort", "active_turn_id", "last_error_code", "created_at", "updated_at", "last_activity_at", ) } | { @@ -1923,6 +1931,7 @@ def public_session(self, payload: dict[str, Any]) -> dict[str, Any]: if revision is not None else None ), "session_mode": session_mode, + "model_settings_supported": session_model_settings_supported(payload), "executor_endpoint_id": str( payload.get("executor_endpoint_id") or payload.get("agent_id") or "" ), diff --git a/tests/test_chat_agent_model_http.py b/tests/test_chat_agent_model_http.py index f833f7f6a2..b92ffe8da7 100644 --- a/tests/test_chat_agent_model_http.py +++ b/tests/test_chat_agent_model_http.py @@ -12,6 +12,187 @@ from loopx.chat_store import ChatSessionStore +@pytest.fixture +def current_session_settings(tmp_path, monkeypatch): + from loopx.chat_runtime import ChatRuntimeController + + store = ChatSessionStore(tmp_path / "runtime") + home = tmp_path / "codex-home" + home.mkdir() + controller = ChatRuntimeController(store=store, codex_bin="codex") + controller.codex_home = home + monkeypatch.setattr(chat_server.ChatRequestHandler, "_registry_and_goal", lambda *_: ({}, {})) + monkeypatch.setattr(chat_server, "_goal_public_context", lambda *_: { + "project": tmp_path, "objective": "Keep this conversation.", "title": "Fixture"}) + monkeypatch.setattr("loopx.chat_providers.codex_model_options", lambda *_args, **_kwargs: { + "options": [{"value": "first", "reasoning_efforts": ["low", "high"]}, + {"value": "second", "reasoning_efforts": ["low", "high"]}]}) + session = store.create_session(goal_id="fixture", agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id="original-thread", agent_model_id="first", agent_reasoning_effort="low", + requested_agent_model_id="first", requested_agent_reasoning_effort="low", upstream_mode="chat") + from loopx.chat_coordination import PROJECT_CONTEXT_VERSION + session = store.update_session(session["session_id"], coordination_context_version=PROJECT_CONTEXT_VERSION) + server = chat_server.ChatHTTPServer(("127.0.0.1", 0), chat_server.ChatRequestHandler) + server.verbose = False + server.chat_store, server.runtime_controller = store, controller + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def post(body, action="model-settings"): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + try: + connection.request("POST", f"/api/chat/sessions/{session['session_id']}/{action}", + body=json.dumps(body), headers={"Content-Type": "application/json", + "Origin": f"http://127.0.0.1:{server.server_port}"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + try: + yield store, session, post + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + controller.close() + + +@pytest.mark.parametrize("busy", [False, True]) +def test_current_session_settings_preserve_identity_active_work_and_actual_readback(current_session_settings, busy): + store, session, post = current_session_settings + sid = session["session_id"] + if busy: + store.update_session(sid, status="busy", active_turn_id="existing-turn") + before = store.load_session(sid) + status, body = post({"agent_model_id": "second", "agent_reasoning_effort": "high"}) + assert status == 200, body + after = store.load_session(sid) + assert after["session_id"] == sid and after["upstream_thread_id"] == "original-thread" + assert after["active_turn_id"] == before["active_turn_id"] + assert after["agent_model_id"] == "first" and after["agent_reasoning_effort"] == "low" + assert body["session"]["requested_agent_model_id"] == "second" + assert body["session"]["requested_agent_reasoning_effort"] == "high" + assert not store.messages(sid) and not list((store.sessions_root / sid / "turns").iterdir()) + reopened = ChatSessionStore(store.root.parent).load_session(sid) + assert reopened == after + + +@pytest.mark.parametrize("changes", [ + {"agent_id": "claude-code"}, {"session_mode": "attached_host"}, + {"managed_agent_id": "registered-worker"}, {"channel_id": "manager"}, + {"steward_context": {}}, {"upstream_mode": "chat_native_goal"}, + {"upstream_mode": "default"}, {"coordination_context_version": 0}, + {"project_context": {"audience": "bound_owner"}}, +]) +def test_host_owned_and_legacy_sessions_do_not_offer_or_admit_model_changes(current_session_settings, monkeypatch, changes): + store, session, post = current_session_settings + # Feed existing persisted identity to the reader; never test by rewriting a + # live authority or granting a fabricated external audience. + original = store.load_session(session["session_id"]) + before = {**original, **changes} + monkeypatch.setattr(store, "load_session", lambda _sid: dict(before)) + monkeypatch.setattr(chat_server.ChatRequestHandler, "_session_context", lambda *_: {}) + assert store.public_session(before)["model_settings_supported"] is False + status, body = post({"agent_model_id": "second", "agent_reasoning_effort": "high"}) + assert status == 424 and body["error_code"] == "unsupported_configuration" + assert ChatSessionStore(store.root.parent).load_session(session["session_id"]) == original + + +def test_current_session_setting_changes_are_isolated(current_session_settings): + store, session, post = current_session_settings + other = store.create_session(goal_id="fixture", agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id="another-thread", agent_model_id="first", upstream_mode="chat") + assert post({"agent_model_id": "second", "agent_reasoning_effort": "high"})[0] == 200 + assert store.load_session(other["session_id"]) == other + + +@pytest.mark.parametrize("message,expected_status", [("Original message.", 202), ("Conflicting message.", 400)]) +def test_completed_request_retry_keeps_its_receipt_after_later_settings_change(current_session_settings, monkeypatch, message, expected_status): + from loopx.chat_runtime import ChatRuntimeController + store, session, post = current_session_settings + sid = session["session_id"] + accepted = store.accept_managed_turn(sid, client_turn_id="original-send", message="Original message.") + store.update_turn(sid, accepted.turn["turn_id"], status="completed", response={"message": "Original answer."}) + store.update_session(sid, status="ready", active_turn_id=None) + assert post({"agent_model_id": "second", "agent_reasoning_effort": "high"})[0] == 200 + before = store.load_session(sid) + monkeypatch.setattr(ChatRuntimeController, "_ensure_adapter_locked", lambda *_a, **_kw: pytest.fail( + "Recovering an existing receipt cannot prepare or dispatch a later configuration")) + status, body = post({"client_turn_id": "original-send", "message": message}, "turns") + assert status == expected_status + if status == 202: + assert body["created"] is False and body["turn_id"] == accepted.turn["turn_id"] + assert store.load_session(sid) == before + + +@pytest.mark.parametrize("failure", [ValueError("grant revoked"), OSError("provider unavailable"), KeyError("closed")]) +def test_configuration_preparation_failure_is_known_non_delivery(current_session_settings, monkeypatch, failure): + from loopx.chat_runtime import ChatRuntimeController + store, session, post = current_session_settings + assert post({"agent_model_id": "second", "agent_reasoning_effort": "high"})[0] == 200 + monkeypatch.setattr(ChatRuntimeController, "_ensure_adapter_locked", lambda *_a, **_kw: (_ for _ in ()).throw(failure)) + monkeypatch.setattr(store, "accept_managed_turn", lambda *_a, **_kw: pytest.fail("Failed preparation must precede admission")) + status, body = post({"client_turn_id": "explicit-send", "message": "Keep this draft."}, "turns") + assert status == 424 and body["delivery_state"] == "not_delivered" and body["turn_replay_safe"] is True + assert not store.messages(session["session_id"]) + assert not list((store.sessions_root / session["session_id"] / "turns").iterdir()) + + +@pytest.mark.parametrize("client_id", ["invalid/id", "invalid id", "invalid:identity"]) +def test_invalid_turn_identity_is_rejected_before_pending_model_preparation(current_session_settings, monkeypatch, client_id): + from loopx.chat_agent import CodexChatAgentError + from loopx.chat_runtime import ChatRuntimeController + store, session, post = current_session_settings + assert post({"agent_model_id": "second", "agent_reasoning_effort": "high"})[0] == 200 + before = store.load_session(session["session_id"]) + preparations = [] + def prepare(*_args, **_kwargs): + preparations.append(True) + raise CodexChatAgentError("Native configuration rejected", error_code="unsupported_configuration", gate=None) + monkeypatch.setattr(ChatRuntimeController, "_ensure_adapter_locked", prepare) + status, body = post({"client_turn_id": client_id, "message": "Invalid identity must start no native operation."}, "turns") + assert status == 400 and "compact opaque id" in body["error"] + assert not preparations + assert store.load_session(session["session_id"]) == before + assert not store.messages(session["session_id"]) + + +@pytest.mark.parametrize("body", [{"agent_model_id": "unknown"}, + {"agent_model_id": "second", "agent_reasoning_effort": "unsupported"}, + {"agent_model_id": "second", "origin": "lark"}, {"agent_reasoning_effort": "high"}]) +def test_current_session_settings_refuse_invalid_or_claimed_authority_without_changes(current_session_settings, body): + store, session, post = current_session_settings + before = store.load_session(session["session_id"]) + status, _ = post(body) + assert status in {400, 424} + assert store.load_session(session["session_id"]) == before + + +def test_current_session_settings_do_not_modify_a_session_closed_during_catalog_read(current_session_settings, monkeypatch): + store, session, post = current_session_settings + def catalog(*_args, **_kwargs): + store.update_session(session["session_id"], status="closed") + return {"options": [{"value": "second", "reasoning_efforts": ["low", "high"]}]} + monkeypatch.setattr("loopx.chat_providers.codex_model_options", catalog) + status, _ = post({"agent_model_id": "second", "agent_reasoning_effort": "high"}) + assert status == 404 + saved = store.load_session(session["session_id"]) + assert saved["status"] == "closed" and saved["requested_agent_model_id"] == "first" + + +@pytest.mark.parametrize("failure", ["unavailable", "write"]) +def test_current_session_settings_failure_preserves_previous_values(current_session_settings, monkeypatch, failure): + store, session, post = current_session_settings + before = store.load_session(session["session_id"]) + if failure == "unavailable": + monkeypatch.setattr("loopx.chat_providers.codex_model_options", lambda *_a, **_kw: None) + else: + monkeypatch.setattr(store, "update_session", lambda *_a, **_kw: (_ for _ in ()).throw(OSError("write refused"))) + status, _ = post({"agent_model_id": "second", "agent_reasoning_effort": "high"}) + assert status == (424 if failure == "unavailable" else 503) + assert store.load_session(session["session_id"]) == before + + @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) diff --git a/tests/test_chat_reasoning_selection.py b/tests/test_chat_reasoning_selection.py index 760f62fbd7..db9b5c5101 100644 --- a/tests/test_chat_reasoning_selection.py +++ b/tests/test_chat_reasoning_selection.py @@ -229,7 +229,7 @@ def test_acp_restart_records_provider_restored_settings_instead_of_failing(tmp_p def test_codex_catalog_failure_is_retryable_without_inventing_current_model(tmp_path, monkeypatch): runtime = ChatRuntimeController(store=ChatSessionStore(tmp_path / "runtime"), codex_bin="fixture") monkeypatch.setattr(runtime, "capabilities", lambda: [{"agent_id": "codex", "available": True}]) - monkeypatch.setattr("loopx.chat_runtime.codex_model_options", lambda *_args, **_kwargs: None) + monkeypatch.setattr("loopx.chat_providers.codex_model_options", lambda *_args, **_kwargs: None) try: with pytest.raises(CodexChatAgentError) as error: runtime.inspect_agent_model_options("codex", work_dir=tmp_path)