summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
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 /packages/transport-http/src
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.
Diffstat (limited to 'packages/transport-http/src')
-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
7 files changed, 458 insertions, 2 deletions
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() {},
};
}