diff --git a/coding-agent/webui/app/src/App.test.tsx b/coding-agent/webui/app/src/App.test.tsx index d7ad2dfdb..9e52cf297 100644 --- a/coding-agent/webui/app/src/App.test.tsx +++ b/coding-agent/webui/app/src/App.test.tsx @@ -695,4 +695,142 @@ describe("App session switching", () => { ); expect(screen.getByText("→ approved")).toBeTruthy(); }); + + it("sends provider and model from the header config when creating a session", async () => { + let createBody: Record | null = null; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions") && init?.method === "POST") { + createBody = JSON.parse(String(init.body)) as Record; + return Promise.resolve(jsonResponse({ session_id: "session-new-0001" })); + } + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.endsWith("/sessions/session-new-0001/runs")) { + return Promise.resolve(jsonResponse({ session_id: "session-new-0001", runs: [] })); + } + if (url.includes("/sessions/session-new-0001/memory/reviews")) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "New session" })); + + await waitFor(() => expect(createBody).not.toBeNull()); + expect(createBody).toMatchObject({ + approval_policy: "auto", + provider: "kimi-code", + model: "kimi-for-coding", + }); + }); + + it("omits provider and model when server default is selected", async () => { + let createBody: Record | null = null; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions") && init?.method === "POST") { + createBody = JSON.parse(String(init.body)) as Record; + return Promise.resolve(jsonResponse({ session_id: "session-new-0002" })); + } + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.endsWith("/sessions/session-new-0002/runs")) { + return Promise.resolve(jsonResponse({ session_id: "session-new-0002", runs: [] })); + } + if (url.includes("/sessions/session-new-0002/memory/reviews")) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.change(screen.getByTitle("provider"), { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "New session" })); + + await waitFor(() => expect(createBody).not.toBeNull()); + expect(createBody).not.toHaveProperty("provider"); + expect(createBody).not.toHaveProperty("model"); + }); + + it("deletes a session after confirmation and removes it from the list", async () => { + const doomed = session("session-doomed-0001", "model-doomed"); + let deletePath = ""; + let deleteMethod = ""; + vi.stubGlobal("confirm", vi.fn(() => true)); + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [doomed] })); + } + if (url.endsWith(`/sessions/${doomed.session_id}`) && init?.method === "DELETE") { + deletePath = url; + deleteMethod = init.method; + return Promise.resolve( + jsonResponse({ status: "closed", session_id: doomed.session_id }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + expect(await screen.findByRole("button", { name: /model-doomed/i })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /close session/i })); + + await waitFor(() => expect(deletePath).not.toBe("")); + expect(deleteMethod).toBe("DELETE"); + expect(deletePath).toContain(`/sessions/${doomed.session_id}`); + await waitFor(() => + expect(screen.queryByRole("button", { name: /model-doomed/i })).toBeNull(), + ); + }); + + it("surfaces a readable error and keeps the session when delete returns 409", async () => { + const stuck = session("session-stuck-0001", "model-stuck"); + vi.stubGlobal("confirm", vi.fn(() => true)); + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [stuck] })); + } + if (url.endsWith(`/sessions/${stuck.session_id}`) && init?.method === "DELETE") { + return Promise.resolve( + new Response("session turn is owned elsewhere", { status: 409 }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + expect(await screen.findByRole("button", { name: /model-stuck/i })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /close session/i })); + + expect( + await screen.findByText(/close session failed: 409 session turn is owned elsewhere/i), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /model-stuck/i })).toBeTruthy(); + }); }); diff --git a/coding-agent/webui/app/src/App.tsx b/coding-agent/webui/app/src/App.tsx index 34b8e0ba0..4a0805ae4 100644 --- a/coding-agent/webui/app/src/App.tsx +++ b/coding-agent/webui/app/src/App.tsx @@ -33,6 +33,8 @@ type Config = { apiKey: string; repoPath: string; approval: ApprovalPolicy; + provider: string; + model: string; }; function defaultBaseUrl(): string { @@ -44,18 +46,21 @@ function defaultBaseUrl(): string { } function loadConfig(): Config { - try { - const raw = localStorage.getItem(LS_KEY); - if (raw) return JSON.parse(raw) as Config; - } catch { - /* ignore */ - } - return { + const defaults: Config = { baseUrl: defaultBaseUrl(), apiKey: "", repoPath: "", approval: "auto", + provider: "kimi-code", + model: "kimi-for-coding", }; + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) return { ...defaults, ...(JSON.parse(raw) as Partial) }; + } catch { + /* ignore */ + } + return defaults; } export default function App() { @@ -176,9 +181,14 @@ export default function App() { setSessionLoadingState(true); setStreaming(false); setStatus("creating session…"); + // Provider "server default" (empty) means: let the server pick, send neither field. + const provider = config.provider.trim(); + const model = provider ? config.model.trim() : ""; const id = await client.createSession({ repoPath: config.repoPath, approvalPolicy: config.approval, + provider: provider || undefined, + model: model || undefined, }); if (loadSeqRef.current !== loadSeq) return; activeSessionRef.current = id; @@ -205,7 +215,32 @@ export default function App() { }, ]); } - }, [client, config.repoPath, config.approval, refreshSessions, refreshMemory, setSessionLoadingState]); + }, [client, config.repoPath, config.approval, config.provider, config.model, refreshSessions, refreshMemory, setSessionLoadingState]); + + const deleteSession = useCallback(async (id: string) => { + if (!window.confirm(`Close session ${id}? This stops its task and removes it.`)) return; + try { + await client.closeSession(id); + setSessions((prev) => prev.filter((it) => it.session_id !== id)); + if (activeSessionRef.current === id) { + loadSeqRef.current += 1; + abortRef.current?.abort(); + abortRef.current = null; + followAbortRef.current?.abort(); + followAbortRef.current = null; + activeSessionRef.current = null; + setSessionId(null); + setItems([]); + setDiffState(null); + setMemoryState(null); + setStreaming(false); + setSessionLoadingState(false); + setStatus("no session"); + } + } catch (e) { + setSessionsError(`close session failed: ${msg(e)}`); + } + }, [client, setSessionLoadingState]); const followSession = useCallback(async (id: string) => { const ctrl = new AbortController(); @@ -423,6 +458,7 @@ export default function App() { error={sessionsError} onRefresh={refreshSessions} onSelect={loadSession} + onDelete={deleteSession} />
diff --git a/coding-agent/webui/app/src/components/Header.tsx b/coding-agent/webui/app/src/components/Header.tsx index 41c6ba408..e5dc21004 100644 --- a/coding-agent/webui/app/src/components/Header.tsx +++ b/coding-agent/webui/app/src/components/Header.tsx @@ -5,8 +5,25 @@ interface Config { apiKey: string; repoPath: string; approval: ApprovalPolicy; + provider: string; + model: string; } +// Mirrors ProviderName in src/coding_agent/server/schemas.py. +const PROVIDERS = [ + "openai", + "anthropic", + "copilot", + "kimi", + "kimi-code", + "kimi-code-anthropic", + "deepseek", + "stepfun", + "codex", +] as const; + +const MODEL_PRESETS = ["kimi-for-coding", "k3", "deepseek-chat"] as const; + interface Props { config: Config; onConfigChange: (patch: Partial) => void; @@ -45,6 +62,33 @@ export default function Header({ value={config.repoPath} onChange={(e) => onConfigChange({ repoPath: e.target.value })} /> + + onConfigChange({ model: e.target.value })} + /> + + {MODEL_PRESETS.map((m) => ( +