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 /packages/transport-http/src | |
| 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.
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 266 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 50 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 1 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 93 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 43 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 4 |
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() {}, }; } |
