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/cli | |
| 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/cli')
| -rw-r--r-- | packages/cli/src/args.test.ts | 43 | ||||
| -rw-r--r-- | packages/cli/src/args.ts | 27 | ||||
| -rw-r--r-- | packages/cli/src/http.test.ts | 74 | ||||
| -rw-r--r-- | packages/cli/src/main.ts | 6 | ||||
| -rw-r--r-- | packages/cli/src/message.test.ts | 16 | ||||
| -rw-r--r-- | packages/cli/src/message.ts | 4 |
6 files changed, 166 insertions, 4 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 }), }; } |
