diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:02:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:02:08 +0900 |
| commit | 2503eba38b7885ab92a9c0e4f082323d1b3a8679 (patch) | |
| tree | cc9ccc2184510d93c601a98ed08a05fdeb146a6f /packages/api/tests | |
| parent | 3f629a8469fe483243671e1ca15582a111e96541 (diff) | |
| download | dispatch-2503eba38b7885ab92a9c0e4f082323d1b3a8679.tar.gz dispatch-2503eba38b7885ab92a9c0e4f082323d1b3a8679.zip | |
feat(agents): per-model reasoning effort level
Add a per-model/key reasoning effort setting to agent definitions,
surfaced and editable in the Agent Settings page and displayed at a
glance in the model selector views.
- core: single source of truth for effort levels (REASONING_EFFORTS,
DEFAULT_REASONING_EFFORT='high', labels, isReasoningEffort guard);
add 'xhigh' level; AgentModelEntry.effort; xhigh budget=24000 for
classic-thinking Claude; default floor 'high'. Persist/parse effort
in the agent TOML loader.
- api: thread effort through the fallback chain with per-model -> per-tab
-> default precedence; validate /chat + agentModels effort from the
canonical list.
- frontend: effort <select> per model row in AgentBuilder; effort badges
in ModelSelector (agent + subagent chains); Thinking dropdown sourced
from canonical list; per-tab default raised to 'high'.
- tests: +15 (loader round-trip, agent xhigh budget, canonical list +
guard, api precedence, route validation).
Diffstat (limited to 'packages/api/tests')
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 64 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 33 |
2 files changed, 96 insertions, 1 deletions
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index b9b4510..ffd87b5 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -80,6 +80,13 @@ function resetConstructedAgents(): void { constructedAgents.length = 0; } +// Capture the per-call `run()` options (notably reasoningEffort) so tests can +// assert the per-model → per-tab → default effort resolution. +const capturedRunOptions: Array<{ reasoningEffort?: string } | undefined> = []; +function resetCapturedRunOptions(): void { + capturedRunOptions.length = 0; +} + // Configurable settings store so tests can toggle tool permissions // (perm_send_to_tab / perm_read_tab / ...) and assert which tools the // constructed Agent receives. Defaults to empty (getSetting → null). @@ -135,7 +142,7 @@ vi.mock("@dispatch/core", () => ({ constructor(config: { tools?: Array<{ name: string }> }) { this.toolNames = (config?.tools ?? []).map((t) => t.name); } - async *run(message: string): AsyncGenerator<unknown> { + async *run(message: string, options?: { reasoningEffort?: string }): AsyncGenerator<unknown> { // Snapshot the post-construction pre-populated message list // the first thing `run()` does, before the real `Agent.run` // would push the current user message at line 546. Tests @@ -144,6 +151,7 @@ vi.mock("@dispatch/core", () => ({ initialMessages: [...this.messages], toolNames: [...this.toolNames], }); + capturedRunOptions.push(options); if (runImpl) { for await (const ev of runImpl(message)) yield ev; return; @@ -345,6 +353,11 @@ vi.mock("@dispatch/core", () => ({ getSetting(key: string) { return fakeSettings.get(key) ?? null; }, + isReasoningEffort(value: unknown) { + return ( + typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value) + ); + }, appendChunks() { return []; }, @@ -402,6 +415,7 @@ describe("AgentManager", () => { beforeEach(() => { resetFakeMessages(); resetConstructedAgents(); + resetCapturedRunOptions(); resetFakeTabs(); resetFakeSettings(); setRunImpl(null); @@ -519,6 +533,54 @@ describe("AgentManager", () => { expect(listener2).toHaveBeenCalled(); }); + // ─── per-model reasoning effort precedence ─────────────────────── + + describe("reasoning effort precedence (per-model → per-tab → default)", () => { + it("uses the per-model effort over the per-tab selector for that fallback entry", async () => { + const manager = new AgentManager(); + // Agent definition supplies a fallback chain where each entry has its + // own configured effort; the per-tab selector ("low") must NOT win. + await manager.processMessage( + "tab-effort-permodel", + "go", + "key-a", + "model-a", + "low", + undefined, + [{ key_id: "key-a", model_id: "model-a", effort: "xhigh" }], + ); + expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("xhigh"); + }); + + it("falls back to the per-tab selector when the model entry has no effort", async () => { + const manager = new AgentManager(); + await manager.processMessage( + "tab-effort-tab", + "go", + "key-a", + "model-a", + "medium", + undefined, + [{ key_id: "key-a", model_id: "model-a" }], + ); + expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("medium"); + }); + + it("passes no effort (Agent applies its default) when neither is set", async () => { + const manager = new AgentManager(); + await manager.processMessage( + "tab-effort-default", + "go", + "key-a", + "model-a", + undefined, + undefined, + [{ key_id: "key-a", model_id: "model-a" }], + ); + expect(capturedRunOptions.at(-1)?.reasoningEffort).toBeUndefined(); + }); + }); + // ─── v6 reasoning-end tests ─────────────────────────────────────── it("reasoning-end event is broadcast to WS listeners", async () => { diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index c768cee..3bf446d 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -169,6 +169,11 @@ vi.mock("@dispatch/core", () => ({ getTab() { return null; }, + isReasoningEffort(value: unknown) { + return ( + typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value) + ); + }, listOpenTabs() { return []; }, @@ -353,6 +358,34 @@ describe("POST /chat", () => { expect(body).toEqual({ status: "ok" }); }); + it("accepts xhigh as a valid reasoningEffort", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tabId: "tab-xhigh", + message: "hello", + reasoningEffort: "xhigh", + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + + it("tolerates an invalid agentModels effort (sanitized, not rejected)", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tabId: "tab-badeffort", + message: "hello", + agentModels: [{ key_id: "k", model_id: "m", effort: "turbo" }], + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + it("returns 400 with empty message", async () => { const res = await app.request("/chat", { method: "POST", |
