summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 20:13:55 +0900
committerAdam Malczewski <[email protected]>2026-06-12 20:13:55 +0900
commit020e051040001320955a70d6dcaab2d833013196 (patch)
tree1a0921487ae3c89befdbccc1754cd399c07ce1b9
parent35197ed933044d322d0a653c4e88a5f3e475fe76 (diff)
downloaddispatch-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.
-rw-r--r--packages/cli/src/args.test.ts43
-rw-r--r--packages/cli/src/args.ts27
-rw-r--r--packages/cli/src/http.test.ts74
-rw-r--r--packages/cli/src/main.ts6
-rw-r--r--packages/cli/src/message.test.ts16
-rw-r--r--packages/cli/src/message.ts4
-rw-r--r--packages/conversation-store/src/keys.ts4
-rw-r--r--packages/conversation-store/src/store.test.ts75
-rw-r--r--packages/conversation-store/src/store.ts17
-rw-r--r--packages/session-orchestrator/src/index.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts153
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts54
-rw-r--r--packages/session-orchestrator/src/pure.test.ts33
-rw-r--r--packages/session-orchestrator/src/pure.ts19
-rw-r--r--packages/transport-http/src/app.test.ts266
-rw-r--r--packages/transport-http/src/app.ts50
-rw-r--r--packages/transport-http/src/extension.ts1
-rw-r--r--packages/transport-http/src/index.ts3
-rw-r--r--packages/transport-http/src/logic.test.ts93
-rw-r--r--packages/transport-http/src/logic.ts43
-rw-r--r--packages/transport-http/src/server.bun.test.ts4
-rw-r--r--packages/transport-ws/src/extension.ts3
-rw-r--r--packages/transport-ws/src/router.test.ts48
-rw-r--r--packages/transport-ws/src/router.ts18
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 } : {}),
};
}