diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 20:13:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 20:13:55 +0900 |
| commit | 020e051040001320955a70d6dcaab2d833013196 (patch) | |
| tree | 1a0921487ae3c89befdbccc1754cd399c07ce1b9 | |
| parent | 35197ed933044d322d0a653c4e88a5f3e475fe76 (diff) | |
| download | dispatch-020e051040001320955a70d6dcaab2d833013196.tar.gz dispatch-020e051040001320955a70d6dcaab2d833013196.zip | |
feat(reasoning-effort): persisted per-conversation + per-turn override, threaded to providers
- conversation-store: get/setReasoningEffort (own key space, mirrors cwd)
- session-orchestrator: resolveReasoningEffort (override -> stored -> 'high'),
StartTurnInput.reasoningEffort, warm() parity (cache-safe)
- transport-http: /chat validation (400 on bad level) + GET/PUT
/conversations/:id/reasoning-effort
- transport-ws: chat.send threading + validation
- cli: --effort <low|medium|high|xhigh|max>
993 vitest + 189 bun tests green; typecheck + biome clean.
24 files changed, 1033 insertions, 22 deletions
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index 02b9e9b..ce278bb 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -49,6 +49,7 @@ describe("parseArgs", () => { file: undefined, cwd: undefined, conversationId: undefined, + reasoningEffort: undefined, showReasoning: false, }); }); @@ -63,6 +64,7 @@ describe("parseArgs", () => { file: "foo.txt", cwd: undefined, conversationId: undefined, + reasoningEffort: undefined, showReasoning: false, }); }); @@ -96,10 +98,51 @@ describe("parseArgs", () => { file: undefined, cwd: "/tmp", conversationId: "abc", + reasoningEffort: undefined, showReasoning: true, }); }); + it("parses --effort high", () => { + const result = parseArgs(["m", "--text", "x", "--effort", "high"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "m", + text: "x", + file: undefined, + cwd: undefined, + conversationId: undefined, + reasoningEffort: "high", + showReasoning: false, + }); + }); + + it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts --effort %s", (level) => { + const result = parseArgs(["m", "--text", "x", "--effort", level], { defaultServer }); + expect(result.kind).toBe("chat"); + if (result.kind === "chat") expect(result.reasoningEffort).toBe(level); + }); + + it("errors when --effort has no value", () => { + const result = parseArgs(["m", "--text", "x", "--effort"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--effort requires a value"); + }); + + it("errors on invalid effort level", () => { + const result = parseArgs(["m", "--text", "x", "--effort", "banana"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toContain("banana"); + expect(result.message).toContain("low"); + expect(result.message).toContain("medium"); + expect(result.message).toContain("high"); + expect(result.message).toContain("xhigh"); + expect(result.message).toContain("max"); + } + }); + it("errors when text and file are both missing", () => { const result = parseArgs(["my-model"], { defaultServer }); expect(result.kind).toBe("error"); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 2c554e8..b3fa1e5 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -5,6 +5,14 @@ * Validates required flags and reports unknown flags as errors. */ +export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; + +const VALID_EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + +export function isValidEffort(value: string): value is ReasoningEffort { + return (VALID_EFFORTS as readonly string[]).includes(value); +} + export type ParsedCommand = | { readonly kind: "models"; readonly server: string } | { @@ -15,6 +23,7 @@ export type ParsedCommand = readonly file?: string | undefined; readonly cwd?: string | undefined; readonly conversationId?: string | undefined; + readonly reasoningEffort?: ReasoningEffort | undefined; readonly showReasoning: boolean; } | { readonly kind: "help" } @@ -53,6 +62,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma let file: string | undefined; let cwd: string | undefined; let conversationId: string | undefined; + let reasoningEffort: ReasoningEffort | undefined; let showReasoning = false; let server = opts.defaultServer; @@ -83,6 +93,22 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma case "--show-reasoning": showReasoning = true; break; + case "--effort": + if (i + 1 >= argv.length) + return { + kind: "error", + message: `--effort requires a value (one of: ${VALID_EFFORTS.join(", ")})`, + }; + { + const val = argv[++i] as string; + if (!isValidEffort(val)) + return { + kind: "error", + message: `Invalid effort level "${val}". Must be one of: ${VALID_EFFORTS.join(", ")}`, + }; + reasoningEffort = val; + } + break; default: return { kind: "error", message: `Unknown flag: ${arg}` }; } @@ -103,6 +129,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma file, cwd, conversationId, + reasoningEffort, showReasoning, }; } diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts index becfdbb..180ea98 100644 --- a/packages/cli/src/http.test.ts +++ b/packages/cli/src/http.test.ts @@ -1,4 +1,4 @@ -import type { AgentEvent } from "@dispatch/transport-contract"; +import type { AgentEvent, ChatRequest } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { fetchModels, streamChat } from "./http.js"; @@ -135,6 +135,78 @@ describe("streamChat", () => { ), ).rejects.toThrow("no body"); }); + + it("includes reasoningEffort in request body when set", async () => { + let capturedBody: string | undefined; + const doneEvent: AgentEvent = { + type: "done", + conversationId: "c", + turnId: "t", + reason: "completed", + }; + const fakeFetch = async ( + _url: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + capturedBody = init?.body as string; + const encoder = new TextEncoder(); + const stream = new ReadableStream<Uint8Array>({ + pull(controller) { + controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`)); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + }; + + await streamChat( + { fetchImpl: fakeFetch as unknown as typeof fetch }, + { + server: "http://localhost:24203", + request: { message: "hi", reasoningEffort: "xhigh" }, + }, + ); + + expect(capturedBody).toBeDefined(); + const parsed = JSON.parse(capturedBody as string) as ChatRequest; + expect(parsed.reasoningEffort).toBe("xhigh"); + }); + + it("omits reasoningEffort from request body when not set", async () => { + let capturedBody: string | undefined; + const doneEvent: AgentEvent = { + type: "done", + conversationId: "c", + turnId: "t", + reason: "completed", + }; + const fakeFetch = async ( + _url: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + capturedBody = init?.body as string; + const encoder = new TextEncoder(); + const stream = new ReadableStream<Uint8Array>({ + pull(controller) { + controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`)); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + }; + + await streamChat( + { fetchImpl: fakeFetch as unknown as typeof fetch }, + { + server: "http://localhost:24203", + request: { message: "hi" }, + }, + ); + + expect(capturedBody).toBeDefined(); + const parsed = JSON.parse(capturedBody as string) as ChatRequest; + expect(parsed).not.toHaveProperty("reasoningEffort"); + }); }); describe("fetchModels", () => { diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index fc70c0c..bf4f603 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -14,8 +14,10 @@ import { renderEvent } from "./render.js"; const USAGE = `Usage: dispatch models [--server <url>] - dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--server <url>] [--show-reasoning] - dispatch --help`; + dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--server <url>] [--show-reasoning] + dispatch --help + +Effort levels: low, medium, high (default), xhigh, max`; async function main(): Promise<void> { const defaultServer = `http://localhost:${process.env.BACKEND_PORT ?? "24203"}`; diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts index 8d6d9e1..a3f1e0b 100644 --- a/packages/cli/src/message.test.ts +++ b/packages/cli/src/message.test.ts @@ -78,4 +78,20 @@ describe("buildChatRequest", () => { ); expect(req.cwd).toBe("/explicit"); }); + + it("includes reasoningEffort when provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", reasoningEffort: "xhigh", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req.reasoningEffort).toBe("xhigh"); + }); + + it("omits reasoningEffort when not provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req).not.toHaveProperty("reasoningEffort"); + }); }); diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts index 0c3f538..80befec 100644 --- a/packages/cli/src/message.ts +++ b/packages/cli/src/message.ts @@ -5,7 +5,7 @@ * and builds a ChatRequest from a parsed command. */ -import type { ChatRequest } from "@dispatch/transport-contract"; +import type { ChatRequest, ReasoningEffort } from "@dispatch/transport-contract"; interface ComposeInput { readonly text?: string; @@ -37,6 +37,7 @@ interface ChatCmd { readonly file?: string | undefined; readonly cwd?: string | undefined; readonly conversationId?: string | undefined; + readonly reasoningEffort?: ReasoningEffort | undefined; readonly showReasoning: boolean; } @@ -51,5 +52,6 @@ export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest { model: cmd.modelName, ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }), ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }), + ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }), }; } diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index e7204db..5eaed70 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -49,3 +49,7 @@ export function parseMetricsOrdinal(key: string): number { export function cwdKey(conversationId: string): string { return `conv:${conversationId}:cwd`; } + +export function reasoningEffortKey(conversationId: string): string { + return `conv:${conversationId}:reasoning-effort`; +} diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 5b07eca..65c6aed 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -884,3 +884,78 @@ describe("ConversationStore cwd", () => { expect(await store.getCwd("convB")).toBe("/path/b"); }); }); + +describe("ConversationStore reasoning effort", () => { + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("setReasoningEffort then getReasoningEffort returns the level", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("conv1", "high"); + const result = await store.getReasoningEffort("conv1"); + expect(result).toBe("high"); + }); + + it("getReasoningEffort returns null when never set", async () => { + const store = createConversationStore(storage); + const result = await store.getReasoningEffort("conv_unknown"); + expect(result).toBeNull(); + }); + + it("reasoning effort of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("convA", "low"); + await store.setReasoningEffort("convB", "max"); + expect(await store.getReasoningEffort("convA")).toBe("low"); + expect(await store.getReasoningEffort("convB")).toBe("max"); + }); + + it("setReasoningEffort is an upsert (second set overwrites)", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("conv1", "medium"); + await store.setReasoningEffort("conv1", "xhigh"); + const result = await store.getReasoningEffort("conv1"); + expect(result).toBe("xhigh"); + }); + + it("reasoning effort persists across a fresh store instance on the same storage", async () => { + const store1 = createConversationStore(storage); + await store1.setReasoningEffort("conv1", "max"); + + const store2 = createConversationStore(storage); + const result = await store2.getReasoningEffort("conv1"); + expect(result).toBe("max"); + }); + + it("reasoning-effort keys do not collide with chunk/cwd/metrics key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/some/path"); + await store.setReasoningEffort("conv1", "low"); + + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + const messages = await store.load("conv1"); + expect(messages).toEqual([msg]); + + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + + expect(await store.getCwd("conv1")).toBe("/some/path"); + expect(await store.getReasoningEffort("conv1")).toBe("low"); + + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); +}); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 0a42917..fdbb2fb 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -2,6 +2,7 @@ import type { ChatMessage, Chunk, Logger, + ReasoningEffort, Role, StorageNamespace, StoredChunk, @@ -16,6 +17,7 @@ import { metricsPrefix, metricsSeqKey, parseSeq, + reasoningEffortKey, seqKey, } from "./keys.js"; import { reconcileWithReport } from "./reconcile.js"; @@ -57,6 +59,10 @@ export interface ConversationStore { readonly getCwd: (conversationId: string) => Promise<string | null>; /** Persist (upsert) the working directory for a conversation. */ readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; + /** The persisted reasoning-effort level for a conversation, or null if never set. */ + readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; + /** Persist (upsert) the reasoning-effort level for a conversation. */ + readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise<void>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -213,5 +219,16 @@ export function createConversationStore( logger.debug("cwd set", { conversationId }); } }, + + async getReasoningEffort(conversationId) { + return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; + }, + + async setReasoningEffort(conversationId, effort) { + await storage.set(reasoningEffortKey(conversationId), effort); + if (logger !== undefined) { + logger.debug("reasoning-effort set", { conversationId }); + } + }, }; } diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index b99c15e..711fa5a 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -25,6 +25,7 @@ export { buildUserMessage, defaultDispatchPolicy, generateTurnId, + resolveReasoningEffort, selectFirstProvider, } from "./pure.js"; export { type ToolAssembly, toolsFilter } from "./tools-filter.js"; diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 799fef5..39996b0 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -7,6 +7,7 @@ import type { ProviderContract, ProviderEvent, ProviderStreamOptions, + ReasoningEffort, RunTurnInput, RunTurnResult, StoredChunk, @@ -27,14 +28,17 @@ function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; readonly metricsData: Map<string, TurnMetrics[]>; readonly cwdData: Map<string, string>; + readonly effortData: Map<string, ReasoningEffort>; } { const data = new Map<string, ChatMessage[]>(); const metricsData = new Map<string, TurnMetrics[]>(); const cwdData = new Map<string, string>(); + const effortData = new Map<string, ReasoningEffort>(); return { data, metricsData, cwdData, + effortData, async append(conversationId, messages) { const existing = data.get(conversationId) ?? []; data.set(conversationId, [...existing, ...messages]); @@ -69,6 +73,12 @@ function createInMemoryStore(): ConversationStore & { async setCwd(conversationId, cwd) { cwdData.set(conversationId, cwd); }, + async getReasoningEffort(conversationId) { + return effortData.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + effortData.set(conversationId, effort); + }, }; } @@ -288,7 +298,7 @@ describe("handleMessage model resolution", () => { expect(captured).toHaveLength(1); expect(captured[0]?.provider).toBe(resolvedProvider); - expect(captured[0]?.providerOpts).toEqual({ model: "gpt-4" }); + expect(captured[0]?.providerOpts).toEqual({ reasoningEffort: "high", model: "gpt-4" }); expect(captured[0]?.cwd).toBe("/work/dir"); }); @@ -349,7 +359,7 @@ describe("handleMessage model resolution", () => { expect(captured).toHaveLength(1); expect(captured[0]?.provider).toBe(fallbackProvider); - expect(captured[0]?.providerOpts).toBeUndefined(); + expect(captured[0]?.providerOpts).toEqual({ reasoningEffort: "high" }); }); it("cwd is forwarded to RunTurnInput.cwd and absent when not provided", async () => { @@ -502,6 +512,12 @@ describe("turn-sealed event", () => { async setCwd(conversationId, cwd) { await store.setCwd(conversationId, cwd); }, + async getReasoningEffort(conversationId) { + return store.getReasoningEffort(conversationId); + }, + async setReasoningEffort(conversationId, effort) { + await store.setReasoningEffort(conversationId, effort); + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -553,6 +569,10 @@ describe("turn-sealed event", () => { return null; }, async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -893,6 +913,10 @@ describe("turn metrics persistence", () => { return null; }, async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -2256,3 +2280,128 @@ describe("closeConversation (CR-4c)", () => { expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false); }); }); + +describe("reasoning effort resolution", () => { + it("override wins over stored → provider receives the override level", async () => { + const store = createInMemoryStore(); + await store.setReasoningEffort("conv-effort-override", "low"); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-effort-override", + text: "hi", + onEvent: () => {}, + reasoningEffort: "max", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("max"); + }); + + it("no override, store has a value → provider receives the stored value", async () => { + const store = createInMemoryStore(); + await store.setReasoningEffort("conv-effort-stored", "xhigh"); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-effort-stored", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("xhigh"); + }); + + it("no override, store empty → provider receives 'high' (default)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-effort-default", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("high"); + }); + + it("warm receives the same resolved effort as a real turn for the same conversation", async () => { + const store = createInMemoryStore(); + await store.append("conv-warm-effort", [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + ]); + await store.setReasoningEffort("conv-warm-effort", "medium"); + + let warmOpts: ProviderStreamOptions | undefined; + + const provider: ProviderContract = { + id: "p", + stream(_messages, _tools, opts) { + warmOpts = opts; + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + emit: () => {}, + }; + + const { orchestrator, activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-effort"); + + await orchestrator.handleMessage({ + conversationId: "conv-warm-effort", + text: "hi", + onEvent: () => {}, + }); + + expect(warmOpts?.reasoningEffort).toBe("medium"); + expect(captured).toHaveLength(1); + expect(captured[0]?.providerOpts?.reasoningEffort).toBe("medium"); + expect(warmOpts?.reasoningEffort).toBe(captured[0]?.providerOpts?.reasoningEffort); + }); +}); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index b0a1083..5b2f264 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -7,6 +7,7 @@ import type { ProviderContract, ProviderEvent, ProviderStreamOptions, + ReasoningEffort, RunTurnInput, RunTurnResult, ToolContract, @@ -15,7 +16,12 @@ import type { } from "@dispatch/kernel"; import { defineEventHook, defineService, type ServiceHandle } from "@dispatch/kernel"; import { createMetricsAccumulator } from "./metrics.js"; -import { buildUserMessage, defaultDispatchPolicy, generateTurnId } from "./pure.js"; +import { + buildUserMessage, + defaultDispatchPolicy, + generateTurnId, + resolveReasoningEffort, +} from "./pure.js"; import type { ToolAssembly } from "./tools-filter.js"; // --- Broadcast hub types --- @@ -25,6 +31,7 @@ export interface StartTurnInput { readonly text: string; readonly modelName?: string; readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; } export type StartTurnResult = @@ -119,6 +126,7 @@ export interface SessionOrchestrator { onEvent: (event: AgentEvent) => void; modelName?: string; cwd?: string; + reasoningEffort?: ReasoningEffort; }): Promise<void>; } @@ -181,6 +189,7 @@ export function createSessionOrchestrator( text: string, modelName: string | undefined, cwd: string | undefined, + reasoningEffortOverride: ReasoningEffort | undefined, ): void { const turnId = generateTurnId(); const controller = new AbortController(); @@ -194,11 +203,15 @@ export function createSessionOrchestrator( ? Promise.resolve(cwd) : deps.conversationStore.getCwd(conversationId).then((c) => c ?? undefined); - const payloadPromise = effectiveCwdPromise.then((effectiveCwd) => ({ - conversationId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(modelName !== undefined ? { modelName } : {}), - })); + const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); + + const payloadPromise = Promise.all([effectiveCwdPromise, storedEffortPromise]).then( + ([effectiveCwd]) => ({ + conversationId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(modelName !== undefined ? { modelName } : {}), + }), + ); payloadPromise.then((payload) => { deps.emit?.(turnStarted, payload); @@ -206,12 +219,17 @@ export function createSessionOrchestrator( void (async () => { try { - const effectiveCwd = await effectiveCwdPromise; + const [effectiveCwd, storedEffort] = await Promise.all([ + effectiveCwdPromise, + storedEffortPromise, + ]); if (cwd !== undefined) { await deps.conversationStore.setCwd(conversationId, cwd); } + const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); + const history = await deps.conversationStore.load(conversationId); const userMsg = buildUserMessage(text); @@ -250,6 +268,11 @@ export function createSessionOrchestrator( emitToHub(conversationId, event); }; + const providerOpts: ProviderStreamOptions = { + reasoningEffort: resolvedEffort, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + }; + const opts: RunTurnInput = { provider, messages: [...history, userMsg], @@ -259,9 +282,7 @@ export function createSessionOrchestrator( conversationId, turnId, signal: controller.signal, - ...(modelOverride !== undefined - ? { providerOpts: { model: modelOverride } satisfies ProviderStreamOptions } - : {}), + providerOpts, ...(turnLogger !== undefined ? { logger: turnLogger } : {}), ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(deps.now !== undefined ? { now: deps.now } : {}), @@ -295,11 +316,11 @@ export function createSessionOrchestrator( } const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd }) { + startTurn({ conversationId, text, modelName, cwd, reasoningEffort }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; } - runTurnDetached(conversationId, text, modelName, cwd); + runTurnDetached(conversationId, text, modelName, cwd, reasoningEffort); const turn = activeTurns.get(conversationId); const turnId = turn !== undefined ? turn.turnId : ""; return { started: true, turnId }; @@ -346,12 +367,13 @@ export function createSessionOrchestrator( return { abortedTurn }; }, - async handleMessage({ conversationId, text, onEvent, modelName, cwd }) { + async handleMessage({ conversationId, text, onEvent, modelName, cwd, reasoningEffort }) { const turnInput: StartTurnInput = { conversationId, text, ...(modelName !== undefined ? { modelName } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), }; const result = orchestrator.startTurn(turnInput); if (!result.started) { @@ -424,6 +446,11 @@ export function createWarmService( ...(cwd !== undefined ? { cwd } : {}), }); + // Resolve reasoning effort the SAME way the real turn does (stored → "high"; + // no per-turn override on warm). A mismatch here silently busts the prompt cache. + const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId); + const resolvedEffort = resolveReasoningEffort(undefined, storedEffort); + const probeMsg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "reply with just a ." }], @@ -439,6 +466,7 @@ export function createWarmService( const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); const providerOpts: ProviderStreamOptions = { maxTokens: 1, + reasoningEffort: resolvedEffort, ...(modelOverride !== undefined ? { model: modelOverride } : {}), ...(warmLogger !== undefined ? { logger: warmLogger } : {}), }; diff --git a/packages/session-orchestrator/src/pure.test.ts b/packages/session-orchestrator/src/pure.test.ts index e233fca..9e5d3c4 100644 --- a/packages/session-orchestrator/src/pure.test.ts +++ b/packages/session-orchestrator/src/pure.test.ts @@ -4,6 +4,7 @@ import { buildUserMessage, defaultDispatchPolicy, generateTurnId, + resolveReasoningEffort, selectFirstProvider, } from "./pure.js"; @@ -67,3 +68,35 @@ describe("generateTurnId", () => { expect(ids.size).toBe(100); }); }); + +describe("resolveReasoningEffort", () => { + it("override wins over stored", () => { + expect(resolveReasoningEffort("low", "high")).toBe("low"); + expect(resolveReasoningEffort("max", "medium")).toBe("max"); + }); + + it("stored wins over default", () => { + expect(resolveReasoningEffort(undefined, "medium")).toBe("medium"); + expect(resolveReasoningEffort(undefined, "xhigh")).toBe("xhigh"); + }); + + it("default is 'high' when both are absent", () => { + expect(resolveReasoningEffort(undefined, null)).toBe("high"); + }); + + it("all 5 levels pass through as override", () => { + expect(resolveReasoningEffort("low", null)).toBe("low"); + expect(resolveReasoningEffort("medium", null)).toBe("medium"); + expect(resolveReasoningEffort("high", null)).toBe("high"); + expect(resolveReasoningEffort("xhigh", null)).toBe("xhigh"); + expect(resolveReasoningEffort("max", null)).toBe("max"); + }); + + it("all 5 levels pass through as stored", () => { + expect(resolveReasoningEffort(undefined, "low")).toBe("low"); + expect(resolveReasoningEffort(undefined, "medium")).toBe("medium"); + expect(resolveReasoningEffort(undefined, "high")).toBe("high"); + expect(resolveReasoningEffort(undefined, "xhigh")).toBe("xhigh"); + expect(resolveReasoningEffort(undefined, "max")).toBe("max"); + }); +}); diff --git a/packages/session-orchestrator/src/pure.ts b/packages/session-orchestrator/src/pure.ts index 46cb79a..85edd14 100644 --- a/packages/session-orchestrator/src/pure.ts +++ b/packages/session-orchestrator/src/pure.ts @@ -1,9 +1,26 @@ -import type { ChatMessage, ProviderContract, ToolDispatchPolicy } from "@dispatch/kernel"; +import type { + ChatMessage, + ProviderContract, + ReasoningEffort, + ToolDispatchPolicy, +} from "@dispatch/kernel"; export function buildUserMessage(text: string): ChatMessage { return { role: "user", chunks: [{ type: "text", text }] }; } +/** + * Resolve the reasoning-effort level for a turn: + * per-turn override → persisted per-conversation value → default `"high"`. + * Pure — no I/O, no ambient state. + */ +export function resolveReasoningEffort( + override: ReasoningEffort | undefined, + stored: ReasoningEffort | null, +): ReasoningEffort { + return override ?? stored ?? "high"; +} + export function selectFirstProvider( providers: ReadonlyMap<string, ProviderContract>, ): ProviderContract { diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 88de38f..1f95dd8 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,6 +1,7 @@ import type { AgentEvent, Logger, + ReasoningEffort, StepId, StorageNamespace, StoredChunk, @@ -80,6 +81,7 @@ function createFakeConversationStore( store: Map<string, StoredChunk[]> = new Map(), metricsStore: Map<string, TurnMetrics[]> = new Map(), cwdStore: Map<string, string> = new Map(), + reasoningEffortStore: Map<string, ReasoningEffort> = new Map(), ): ConversationStore { return { async append() {}, @@ -110,6 +112,12 @@ function createFakeConversationStore( async setCwd(conversationId, cwd) { cwdStore.set(conversationId, cwd); }, + async getReasoningEffort(conversationId) { + return reasoningEffortStore.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + reasoningEffortStore.set(conversationId, effort); + }, }; } @@ -807,6 +815,10 @@ describe("GET /conversations/:id", () => { return null; }, async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, }; const app = createApp({ conversationStore: store, @@ -860,6 +872,10 @@ describe("GET /conversations/:id", () => { return null; }, async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, }; const app = createApp({ conversationStore: store, @@ -982,6 +998,10 @@ describe("GET /conversations/:id/metrics", () => { return null; }, async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, }; const app = createApp({ conversationStore: brokenStore, @@ -1449,3 +1469,249 @@ describe("GET /conversations/:id/lsp", () => { expect(body.servers[1]?.error).toBe("spawn failed"); }); }); + +describe("POST /chat reasoningEffort", () => { + const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + + for (const level of allLevels) { + it(`forwards reasoningEffort="${level}" to orchestrator`, async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: level, + }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.reasoningEffort).toBe(level); + }); + } + + it("omits reasoningEffort from orchestrator input when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.reasoningEffort).toBeUndefined(); + }); + + it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => { + let handleMessageCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + async handleMessage(input) { + handleMessageCalled = true; + return createFakeOrchestrator([]).handleMessage(input); + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: "banana", + }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("reasoningEffort"); + expect(handleMessageCalled).toBe(false); + }); + + it("returns 400 for non-string reasoningEffort", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: 42, + }), + }); + + expect(res.status).toBe(400); + }); +}); + +describe("GET /conversations/:id/reasoning-effort", () => { + it("returns null when never set", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.reasoningEffort).toBeNull(); + }); + + it("returns the level after PUT", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "xhigh" }), + }); + + const res = await app.request("/conversations/conv1/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.reasoningEffort).toBe("xhigh"); + }); + + it("returns null for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/unknown/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.conversationId).toBe("unknown"); + expect(body.reasoningEffort).toBeNull(); + }); +}); + +describe("PUT /conversations/:id/reasoning-effort", () => { + it("persists a valid level and returns it", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "low" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.reasoningEffort).toBe("low"); + }); + + it("returns 400 for an invalid level", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "banana" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("reasoningEffort"); + }); + + it("returns 400 when reasoningEffort is missing from body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("does not call store on validation failure", async () => { + let storeCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setReasoningEffort() { + storeCalled = true; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "invalid" }), + }); + expect(res.status).toBe(400); + expect(storeCalled).toBe(false); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index ae24922..2788bf9 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -7,6 +7,7 @@ import type { LspServerInfo, LspStatusResponse, ModelsResponse, + ReasoningEffortResponse, ThroughputResponse, WarmResponse, } from "@dispatch/transport-contract"; @@ -16,9 +17,11 @@ import { computeCachePct, computeExpectedCacheRate, isParseError, + isReasoningEffortParseError, isSinceSeqError, isWindowParamError, parseChatBody, + parseReasoningEffortBody, parseSinceSeq, parseWarmBody, parseWindowParam, @@ -227,11 +230,12 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json({ error: result.error }, 400); } - const { conversationId, message, model, cwd } = result; + const { conversationId, message, model, cwd, reasoningEffort } = result; log.info("chat: request accepted", { conversationId, hasModel: model !== undefined, hasCwd: cwd !== undefined, + hasReasoningEffort: reasoningEffort !== undefined, }); const events: AgentEvent[] = []; @@ -248,6 +252,7 @@ export function createApp(opts: CreateServerOptions): Hono { }, ...(model !== undefined ? { modelName: model } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), }; const orchestratorPromise = opts.orchestrator @@ -407,6 +412,49 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + app.get("/conversations/:id/reasoning-effort", async (c) => { + const conversationId = c.req.param("id"); + try { + const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId); + log.info("conversations: reasoning-effort read", { + conversationId, + hasEffort: reasoningEffort !== null, + }); + const body: ReasoningEffortResponse = { conversationId, reasoningEffort }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: reasoning-effort read failure", { err }); + return c.json({ error: "Failed to read conversation reasoning effort" }, 500); + } + }); + + app.put("/conversations/:id/reasoning-effort", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/reasoning-effort: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseReasoningEffortBody(body); + if (isReasoningEffortParseError(parsed)) { + log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + try { + await opts.conversationStore.setReasoningEffort(conversationId, parsed); + log.info("conversations: reasoning-effort set", { conversationId }); + const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: reasoning-effort set failure", { err }); + return c.json({ error: "Failed to set conversation reasoning effort" }, 500); + } + }); + app.get("/conversations/:id/lsp", async (c) => { const conversationId = c.req.param("id"); try { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 33b9990..ab23b65 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -31,6 +31,7 @@ export const manifest: Manifest = { "/conversations/:id/close", "/conversations/:id/cwd", "/conversations/:id/lsp", + "/conversations/:id/reasoning-effort", "/health", "/models", "/metrics/throughput", diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index 1a42259..b231b7e 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -12,9 +12,12 @@ export type { export { computeCachePct, isParseError, + isReasoningEffortParseError, isSinceSeqError, + isValidReasoningEffort, isWindowParamError, parseChatBody, + parseReasoningEffortBody, parseSinceSeq, parseWindowParam, serializeEventLine, diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index 9a7bda2..f91b38c 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest"; import { computeExpectedCacheRate, isParseError, + isReasoningEffortParseError, isSinceSeqError, + isValidReasoningEffort, isWindowParamError, parseChatBody, + parseReasoningEffortBody, parseSinceSeq, parseWindowParam, serializeEventLine, @@ -139,6 +142,45 @@ describe("parseChatBody", () => { expect(result.error).toContain("cwd"); } }); + + it("extracts reasoningEffort when present and valid", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBe("low"); + } + }); + + it("accepts all valid reasoningEffort levels", () => { + for (const level of ["low", "medium", "high", "xhigh", "max"]) { + const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBe(level); + } + } + }); + + it("returns error for invalid reasoningEffort", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns error for non-string reasoningEffort", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("omits reasoningEffort when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBeUndefined(); + } + }); }); describe("parseSinceSeq", () => { @@ -275,3 +317,54 @@ describe("computeExpectedCacheRate", () => { expect(computeExpectedCacheRate(2, 1)).toBe(67); }); }); + +describe("isValidReasoningEffort", () => { + it("returns true for all valid levels", () => { + expect(isValidReasoningEffort("low")).toBe(true); + expect(isValidReasoningEffort("medium")).toBe(true); + expect(isValidReasoningEffort("high")).toBe(true); + expect(isValidReasoningEffort("xhigh")).toBe(true); + expect(isValidReasoningEffort("max")).toBe(true); + }); + + it("returns false for invalid strings", () => { + expect(isValidReasoningEffort("banana")).toBe(false); + expect(isValidReasoningEffort("")).toBe(false); + expect(isValidReasoningEffort("LOW")).toBe(false); + }); + + it("returns false for non-strings", () => { + expect(isValidReasoningEffort(42)).toBe(false); + expect(isValidReasoningEffort(null)).toBe(false); + expect(isValidReasoningEffort(undefined)).toBe(false); + expect(isValidReasoningEffort(true)).toBe(false); + }); +}); + +describe("parseReasoningEffortBody", () => { + it("returns the level for a valid body", () => { + expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low"); + expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max"); + }); + + it("returns ParseError for missing reasoningEffort", () => { + const result = parseReasoningEffortBody({}); + expect(isReasoningEffortParseError(result)).toBe(true); + if (isReasoningEffortParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns ParseError for invalid level", () => { + const result = parseReasoningEffortBody({ reasoningEffort: "banana" }); + expect(isReasoningEffortParseError(result)).toBe(true); + if (isReasoningEffortParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns ParseError for non-object body", () => { + expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true); + expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true); + }); +}); diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 0008110..e0adfeb 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -1,10 +1,23 @@ -import type { AgentEvent } from "@dispatch/kernel"; +import type { AgentEvent, ReasoningEffort } from "@dispatch/kernel"; + +const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +]; + +export function isValidReasoningEffort(value: unknown): value is ReasoningEffort { + return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); +} export interface ChatCommand { readonly conversationId: string; readonly message: string; readonly model?: string; readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; } export interface ParseError { @@ -48,6 +61,15 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes (result as { cwd?: string }).cwd = obj.cwd; } + if (obj.reasoningEffort !== undefined) { + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; + } + return result; } @@ -149,3 +171,22 @@ export function computeExpectedCacheRate( if (denom <= 0) return 0; return Math.round((cacheReadTokens / denom) * 100); } + +export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError { + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record<string, unknown>; + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + return obj.reasoningEffort; +} + +export function isReasoningEffortParseError( + result: ReasoningEffort | ParseError, +): result is ParseError { + return typeof result === "object" && result !== null && "error" in result; +} diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index a465243..36b05a5 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -50,6 +50,10 @@ function fakeConversationStore(): ConversationStore { return null; }, async setCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, }; } diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 0c62c66..332ebe0 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -222,6 +222,9 @@ export function createTransportWsExtension(): Extension { text: result.message, ...(result.model !== undefined ? { modelName: result.model } : {}), ...(result.cwd !== undefined ? { cwd: result.cwd } : {}), + ...(result.reasoningEffort !== undefined + ? { reasoningEffort: result.reasoningEffort } + : {}), }); if (!startResult.started) { send(ws, { diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index a3fd91a..a0ad104 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -373,6 +373,54 @@ describe("routeClientMessage", () => { if (result.kind !== "chat-error") throw new Error("expected chat-error"); expect(result.errorMessage).toContain("non-empty string"); }); + + it("threads each valid reasoningEffort level through to the result", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + const levels = ["low", "medium", "high", "xhigh", "max"] as const; + + for (const level of levels) { + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + reasoningEffort: level, + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.reasoningEffort).toBe(level); + } + }); + + it("omits reasoningEffort from result when not provided by client", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result).not.toHaveProperty("reasoningEffort"); + }); + + it("rejects an invalid reasoningEffort value with a chat-error", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello", + reasoningEffort: "turbo" as unknown as "low", + }); + + expect(result.kind).toBe("chat-error"); + if (result.kind !== "chat-error") throw new Error("expected chat-error"); + expect(result.errorMessage).toContain("invalid reasoningEffort"); + expect(result.errorMessage).toContain("turbo"); + }); }); describe("chat.subscribe", () => { diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index 03ae08f..93f97f8 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -12,6 +12,7 @@ import type { ChatSendMessage, ChatSubscribeMessage, ChatUnsubscribeMessage, + ReasoningEffort, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -45,6 +46,7 @@ export interface ChatRouteResult { readonly message: string; readonly model: string | undefined; readonly cwd: string | undefined; + readonly reasoningEffort?: ReasoningEffort; } /** A malformed chat.send that should yield a chat.error reply. */ @@ -121,6 +123,14 @@ export function routeClientMessage( // ── Chat validation ───────────────────────────────────────────────────────── +const VALID_REASONING_EFFORT: ReadonlySet<string> = new Set<ReasoningEffort>([ + "low", + "medium", + "high", + "xhigh", + "max", +]); + function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError { if (typeof msg.message !== "string" || msg.message.length === 0) { return { @@ -129,12 +139,20 @@ function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError errorMessage: "chat.send requires a non-empty string `message`", }; } + if (msg.reasoningEffort !== undefined && !VALID_REASONING_EFFORT.has(msg.reasoningEffort)) { + return { + kind: "chat-error", + conversationId: msg.conversationId, + errorMessage: `chat.send: invalid reasoningEffort "${msg.reasoningEffort}" — must be one of: low, medium, high, xhigh, max`, + }; + } return { kind: "chat", conversationId: msg.conversationId, message: msg.message, model: msg.model, cwd: msg.cwd, + ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}), }; } |
