From 8ce4f95aecbc34bd5bca00b8b0d2b496c35827ca Mon Sep 17 00:00:00 2001 From: uBlue Date: Wed, 2 Sep 2026 12:24:46 +1000 Subject: [PATCH 1/2] fix(coding-agent): request low reasoning for session title generation Unset reasoning on the background title request made token-based providers fall back to their disabled mapping (reasoning: { effort: "none" } on OpenRouter-format models). Reasoning-mandatory endpoints such as Z.ai GLM 5.x reject that with HTTP 400 'Reasoning is mandatory for this endpoint and cannot be disabled.', surfacing as a repeated session_title_generation runtime error in every session on those models. Ask for low reasoning explicitly and raise maxTokens so reasoning tokens leave room for the output. --- packages/coding-agent/src/core/changes.md | 18 ++++++ .../src/core/session-title-generator.ts | 11 +++- .../test/session-title-generator.test.ts | 62 ++++++++++++++++++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index e9794623f6..8722c35ffb 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-02 - Session title requests ask for low reasoning + +### What changed + +- `session-title-generator.ts`: `buildTitleOptions()` now sets `reasoning: "low"` and raises the title request's `maxTokens` from 64 to 1024. + +### Why + +- Leaving `reasoning` unset on the title request makes token-based providers fall back to their "disabled" mapping (e.g. `reasoning: { effort: "none" }` on OpenRouter-format models). Reasoning-mandatory endpoints such as Z.ai GLM 5.x reject that with HTTP 400 `Reasoning is mandatory for this endpoint and cannot be disabled.`, which surfaced as a repeated `session_title_generation` runtime error in every session on those models (agent turns were unaffected because they always carry the session's thinking level). Low reasoning keeps the cosmetic title cheap while producing a request every endpoint accepts; non-reasoning models ignore the hint via level clamping. The larger `maxTokens` keeps room for the `<title>` output once reasoning tokens count against the completion budget. + +### Why an extension could not handle it + +- Title generation is core background work invoked from `agent-session.ts`; extensions cannot alter the request options of the internal title call. + +### Expected merge conflict zones + +- LOW: the `buildTitleOptions()` literal in `session-title-generator.ts` and the new `generateSessionTitle` describe block in `test/session-title-generator.test.ts`. + ## 2026-08-31 - Session activity contract for host occupancy decisions ### What changed diff --git a/packages/coding-agent/src/core/session-title-generator.ts b/packages/coding-agent/src/core/session-title-generator.ts index 3be4f4714b..56e2a047f6 100644 --- a/packages/coding-agent/src/core/session-title-generator.ts +++ b/packages/coding-agent/src/core/session-title-generator.ts @@ -123,7 +123,16 @@ function buildTitleOptions(options: GenerateSessionTitleOptions): SimpleStreamOp ...options.baseOptions, sessionId: options.sessionId, cacheRetention: options.model.cacheRetention === "none" ? "none" : "short", - maxTokens: 64, + // Titles are cosmetic background work, but leaving `reasoning` unset makes + // reasoning-capable models fall back to the provider's "disabled" mapping + // (e.g. `reasoning: { effort: "none" }` on OpenRouter), which + // reasoning-mandatory endpoints (e.g. Z.ai GLM 5.x) reject with HTTP 400 + // "Reasoning is mandatory for this endpoint and cannot be disabled.". + // Ask for low reasoning explicitly instead; non-reasoning models ignore it. + reasoning: "low", + // Low reasoning consumes part of the completion budget on token-based + // providers, so keep room for the actual `<title>` output. + maxTokens: 1024, }; if (options.auth.apiKey !== undefined) { titleOptions.apiKey = options.auth.apiKey; diff --git a/packages/coding-agent/test/session-title-generator.test.ts b/packages/coding-agent/test/session-title-generator.test.ts index a28aa3a84f..87f8c0b848 100644 --- a/packages/coding-agent/test/session-title-generator.test.ts +++ b/packages/coding-agent/test/session-title-generator.test.ts @@ -1,5 +1,65 @@ +import type { + Api, + AssistantMessage, + AssistantMessageEventStream, + Model, + SimpleStreamOptions, +} from "@earendil-works/pi-ai/compat"; import { describe, expect, it } from "vitest"; -import { humanizeProviderError, sessionTitleRetryPolicy } from "../src/core/session-title-generator.ts"; +import { + generateSessionTitle, + humanizeProviderError, + sessionTitleRetryPolicy, +} from "../src/core/session-title-generator.ts"; + +const TITLE_MODEL = { + api: "openai-completions", + provider: "openrouter", + id: "z-ai/glm-5.3-flash", + reasoning: true, + baseUrl: "https://openrouter.ai/api/v1", +} as unknown as Model<Api>; + +function fakeTitleStream(text: string): { + streamFn: NonNullable<Parameters<typeof generateSessionTitle>[0]["streamFn"]>; + capturedOptions: () => SimpleStreamOptions | undefined; +} { + let captured: SimpleStreamOptions | undefined; + const message = { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-completions", + provider: "openrouter", + model: "z-ai/glm-5.3-flash", + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, + stopReason: "stop", + } as unknown as AssistantMessage; + const streamFn = ((_model: unknown, _context: unknown, options: SimpleStreamOptions | undefined) => { + captured = options; + return { result: async () => message } as unknown as AssistantMessageEventStream; + }) as NonNullable<Parameters<typeof generateSessionTitle>[0]["streamFn"]>; + return { streamFn, capturedOptions: () => captured }; +} + +describe("generateSessionTitle", () => { + it("requests low reasoning so reasoning-mandatory endpoints do not reject the title call", async () => { + const { streamFn, capturedOptions } = fakeTitleStream("<title>Fix Login Bug"); + const title = await generateSessionTitle({ + firstPrompt: "Fix the login bug in the auth service", + model: TITLE_MODEL, + auth: {}, + sessionId: "test-session", + streamFn, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + }); + expect(title).toBe("Fix Login Bug"); + const options = capturedOptions(); + // Unset reasoning makes pi-ai send `reasoning: { effort: "none" }` on + // OpenRouter, which reasoning-mandatory endpoints reject with HTTP 400. + expect(options?.reasoning).toBe("low"); + expect(options?.maxTokens).toBe(1024); + }); +}); describe("sessionTitleRetryPolicy", () => { it("caps the cosmetic title retry below the full agent-turn budget", () => { From 94960bae6137a8d7537847b518e6ee8e76468bfd Mon Sep 17 00:00:00 2001 From: uBlue Date: Wed, 2 Sep 2026 13:36:44 +1000 Subject: [PATCH 2/2] fix(coding-agent): gate title reasoning fallback to reasoning-mandatory errors Review follow-up: forcing reasoning: "low" on every title call taxes all reasoning-capable models on a cosmetic background call, and some catalogs map low to full effort (DeepSeek low -> "high" in openai-completions.ts). Keep the default request reasoning-free and instead retry once with low reasoning + maxTokens 1024 only when the provider answers with the reasoning-mandatory 400. Healthy endpoints keep the zero-reasoning path. --- packages/coding-agent/src/core/changes.md | 11 +-- .../src/core/session-title-generator.ts | 50 ++++++++--- .../test/session-title-generator.test.ts | 89 ++++++++++++++----- 3 files changed, 113 insertions(+), 37 deletions(-) diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 8722c35ffb..cc176bac54 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,22 +1,23 @@ # changes -## 2026-09-02 - Session title requests ask for low reasoning +## 2026-09-02 - Session titles fall back to low reasoning on reasoning-mandatory endpoints ### What changed -- `session-title-generator.ts`: `buildTitleOptions()` now sets `reasoning: "low"` and raises the title request's `maxTokens` from 64 to 1024. +- `session-title-generator.ts`: `generateSessionTitle()` keeps the default title request reasoning-free; when the provider returns an error matching "Reasoning is mandatory" it retries once with `reasoning: "low"` and `maxTokens: 1024`. `buildTitleOptions()` takes an optional reasoning level that enables both. ### Why -- Leaving `reasoning` unset on the title request makes token-based providers fall back to their "disabled" mapping (e.g. `reasoning: { effort: "none" }` on OpenRouter-format models). Reasoning-mandatory endpoints such as Z.ai GLM 5.x reject that with HTTP 400 `Reasoning is mandatory for this endpoint and cannot be disabled.`, which surfaced as a repeated `session_title_generation` runtime error in every session on those models (agent turns were unaffected because they always carry the session's thinking level). Low reasoning keeps the cosmetic title cheap while producing a request every endpoint accepts; non-reasoning models ignore the hint via level clamping. The larger `maxTokens` keeps room for the `` output once reasoning tokens count against the completion budget. +- Leaving `reasoning` unset makes token-based providers fall back to their "disabled" mapping (e.g. `reasoning: { effort: "none" }` on OpenRouter-format models), which reasoning-mandatory endpoints such as Z.ai GLM 5.x reject with HTTP 400 `Reasoning is mandatory for this endpoint and cannot be disabled.` — surfacing as a repeated `session_title_generation` runtime error in every session on those models (agent turns were unaffected because they always carry the session's thinking level). +- Forcing `reasoning: "low"` on every title call instead would tax all reasoning-capable models on a cosmetic background call, and some catalogs map `low` to full effort (e.g. DeepSeek `low` -> `"high"` in `openai-completions.ts`). Matching on the specific 400 keeps the zero-reasoning path for every healthy endpoint and pays reasoning only where the endpoint demands it; the `maxTokens` bump leaves room for the `<title>` output once reasoning tokens count against the completion budget. ### Why an extension could not handle it -- Title generation is core background work invoked from `agent-session.ts`; extensions cannot alter the request options of the internal title call. +- Title generation is core background work invoked from `agent-session.ts`; extensions cannot alter the request options or the retry behavior of the internal title call. ### Expected merge conflict zones -- LOW: the `buildTitleOptions()` literal in `session-title-generator.ts` and the new `generateSessionTitle` describe block in `test/session-title-generator.test.ts`. +- LOW: the `generateSessionTitle()` retry block and `buildTitleOptions()` signature in `session-title-generator.ts`, and the `generateSessionTitle` describe block in `test/session-title-generator.test.ts`. ## 2026-08-31 - Session activity contract for host occupancy decisions diff --git a/packages/coding-agent/src/core/session-title-generator.ts b/packages/coding-agent/src/core/session-title-generator.ts index 56e2a047f6..c606fb265c 100644 --- a/packages/coding-agent/src/core/session-title-generator.ts +++ b/packages/coding-agent/src/core/session-title-generator.ts @@ -88,7 +88,12 @@ export async function generateSessionTitle(options: GenerateSessionTitleOptions) // Titles are cosmetic background work: honor the caller's retry policy so a // single transient provider error (e.g. a 529 overloaded stream) does not // surface as a scary runtime error. Mirrors completeSummarization(). - const response = await retryAssistantCall( + // Reasoning-mandatory endpoints (e.g. Z.ai GLM 5.x via OpenRouter) reject the + // reasoning-disabled mapping pi-ai sends when `reasoning` is unset with HTTP + // 400 "Reasoning is mandatory for this endpoint and cannot be disabled."; + // retry those once with low reasoning instead of paying reasoning cost on + // every title call for every provider. + let response = await retryAssistantCall( () => completeTitle( options.model, @@ -99,12 +104,31 @@ export async function generateSessionTitle(options: GenerateSessionTitleOptions) options.retry, options.signal, ); + if (response.stopReason === "error" && isReasoningMandatoryError(response.errorMessage)) { + response = await retryAssistantCall( + () => + completeTitle( + options.model, + buildTitleContext(options.firstPrompt), + buildTitleOptions(options, "low"), + options.streamFn, + ), + options.retry, + options.signal, + ); + } if (response.stopReason === "error") { throw new Error(humanizeProviderError(response.errorMessage ?? "Session title generation failed")); } return parseSessionTitle(response); } +const REASONING_MANDATORY_PATTERN = /reasoning is mandatory/i; + +function isReasoningMandatoryError(errorMessage: string | undefined): boolean { + return errorMessage !== undefined && REASONING_MANDATORY_PATTERN.test(errorMessage); +} + function buildTitleContext(firstPrompt: string): Context { return { systemPrompt: TITLE_SYSTEM_PROMPT, @@ -118,22 +142,24 @@ function buildTitleContext(firstPrompt: string): Context { }; } -function buildTitleOptions(options: GenerateSessionTitleOptions): SimpleStreamOptions { +function buildTitleOptions( + options: GenerateSessionTitleOptions, + reasoning?: SimpleStreamOptions["reasoning"], +): SimpleStreamOptions { const titleOptions: SimpleStreamOptions = { ...options.baseOptions, sessionId: options.sessionId, cacheRetention: options.model.cacheRetention === "none" ? "none" : "short", - // Titles are cosmetic background work, but leaving `reasoning` unset makes - // reasoning-capable models fall back to the provider's "disabled" mapping - // (e.g. `reasoning: { effort: "none" }` on OpenRouter), which - // reasoning-mandatory endpoints (e.g. Z.ai GLM 5.x) reject with HTTP 400 - // "Reasoning is mandatory for this endpoint and cannot be disabled.". - // Ask for low reasoning explicitly instead; non-reasoning models ignore it. - reasoning: "low", - // Low reasoning consumes part of the completion budget on token-based - // providers, so keep room for the actual `<title>` output. - maxTokens: 1024, + maxTokens: 64, }; + if (reasoning !== undefined) { + // Fallback for reasoning-mandatory endpoints: low reasoning keeps the + // cosmetic title cheap, and the larger maxTokens leaves room for the + // `<title>` output once reasoning tokens count against the completion + // budget. Callers that never see the 400 keep the cheap default above. + titleOptions.reasoning = reasoning; + titleOptions.maxTokens = 1024; + } if (options.auth.apiKey !== undefined) { titleOptions.apiKey = options.auth.apiKey; } diff --git a/packages/coding-agent/test/session-title-generator.test.ts b/packages/coding-agent/test/session-title-generator.test.ts index 87f8c0b848..b113aac46e 100644 --- a/packages/coding-agent/test/session-title-generator.test.ts +++ b/packages/coding-agent/test/session-title-generator.test.ts @@ -20,30 +20,39 @@ const TITLE_MODEL = { baseUrl: "https://openrouter.ai/api/v1", } as unknown as Model<Api>; -function fakeTitleStream(text: string): { +function fakeTitleStream(responses: AssistantMessage[]): { streamFn: NonNullable<Parameters<typeof generateSessionTitle>[0]["streamFn"]>; - capturedOptions: () => SimpleStreamOptions | undefined; + capturedOptions: () => (SimpleStreamOptions | undefined)[]; } { - let captured: SimpleStreamOptions | undefined; - const message = { - role: "assistant", - content: [{ type: "text", text }], - api: "openai-completions", - provider: "openrouter", - model: "z-ai/glm-5.3-flash", - usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, - stopReason: "stop", - } as unknown as AssistantMessage; + const captured: (SimpleStreamOptions | undefined)[] = []; const streamFn = ((_model: unknown, _context: unknown, options: SimpleStreamOptions | undefined) => { - captured = options; + captured.push(options); + const message = responses[Math.min(captured.length - 1, responses.length - 1)]; return { result: async () => message } as unknown as AssistantMessageEventStream; }) as NonNullable<Parameters<typeof generateSessionTitle>[0]["streamFn"]>; return { streamFn, capturedOptions: () => captured }; } +const OK_TITLE = { + role: "assistant", + content: [{ type: "text", text: "<title>Fix Login Bug" }], + api: "openai-completions", + provider: "openrouter", + model: "z-ai/glm-5.3-flash", + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, + stopReason: "stop", +} as unknown as AssistantMessage; + +const REASONING_MANDATORY_ERROR = { + ...OK_TITLE, + content: [], + stopReason: "error", + errorMessage: '400: {"error":{"message":"Reasoning is mandatory for this endpoint and cannot be disabled."}}', +} as unknown as AssistantMessage; + describe("generateSessionTitle", () => { - it("requests low reasoning so reasoning-mandatory endpoints do not reject the title call", async () => { - const { streamFn, capturedOptions } = fakeTitleStream("Fix Login Bug"); + it("keeps the default title request reasoning-free so healthy endpoints pay nothing", async () => { + const { streamFn, capturedOptions } = fakeTitleStream([OK_TITLE]); const title = await generateSessionTitle({ firstPrompt: "Fix the login bug in the auth service", model: TITLE_MODEL, @@ -53,11 +62,51 @@ describe("generateSessionTitle", () => { retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 }, }); expect(title).toBe("Fix Login Bug"); - const options = capturedOptions(); - // Unset reasoning makes pi-ai send `reasoning: { effort: "none" }` on - // OpenRouter, which reasoning-mandatory endpoints reject with HTTP 400. - expect(options?.reasoning).toBe("low"); - expect(options?.maxTokens).toBe(1024); + const attempts = capturedOptions(); + expect(attempts).toHaveLength(1); + // Forcing a reasoning level would enable thinking on every title call; + // some catalogs even map `low` to full effort (e.g. DeepSeek low -> "high"). + expect(attempts[0]?.reasoning).toBeUndefined(); + expect(attempts[0]?.maxTokens).toBe(64); + }); + + it("retries once with low reasoning when the endpoint mandates reasoning", async () => { + const { streamFn, capturedOptions } = fakeTitleStream([REASONING_MANDATORY_ERROR, OK_TITLE]); + const title = await generateSessionTitle({ + firstPrompt: "Fix the login bug in the auth service", + model: TITLE_MODEL, + auth: {}, + sessionId: "test-session", + streamFn, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + }); + expect(title).toBe("Fix Login Bug"); + const attempts = capturedOptions(); + expect(attempts).toHaveLength(2); + expect(attempts[0]?.reasoning).toBeUndefined(); + expect(attempts[1]?.reasoning).toBe("low"); + // Reasoning tokens count against the completion budget, so the fallback + // attempt needs headroom for the `` output. + expect(attempts[1]?.maxTokens).toBe(1024); + }); + + it("does not retry other provider errors with reasoning", async () => { + const overloaded = { + ...REASONING_MANDATORY_ERROR, + errorMessage: '529: {"error":{"message":"Overloaded"}}', + } as unknown as AssistantMessage; + const { streamFn, capturedOptions } = fakeTitleStream([overloaded, OK_TITLE]); + await expect( + generateSessionTitle({ + firstPrompt: "Fix the login bug in the auth service", + model: TITLE_MODEL, + auth: {}, + sessionId: "test-session", + streamFn, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + }), + ).rejects.toThrow("Overloaded (HTTP 529)"); + expect(capturedOptions()).toHaveLength(1); }); });