diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index e9794623f6..cc176bac54 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,24 @@ # changes +## 2026-09-02 - Session titles fall back to low reasoning on reasoning-mandatory endpoints + +### What changed + +- `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 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 `` 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 or the retry behavior of the internal title call. + +### Expected merge conflict zones + +- 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 ### 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..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,13 +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", 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 a28aa3a84f..b113aac46e 100644 --- a/packages/coding-agent/test/session-title-generator.test.ts +++ b/packages/coding-agent/test/session-title-generator.test.ts @@ -1,5 +1,114 @@ +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(responses: AssistantMessage[]): { + streamFn: NonNullable<Parameters<typeof generateSessionTitle>[0]["streamFn"]>; + capturedOptions: () => (SimpleStreamOptions | undefined)[]; +} { + const captured: (SimpleStreamOptions | undefined)[] = []; + const streamFn = ((_model: unknown, _context: unknown, options: SimpleStreamOptions | undefined) => { + 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("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, + auth: {}, + sessionId: "test-session", + streamFn, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + }); + expect(title).toBe("Fix Login Bug"); + 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); + }); +}); describe("sessionTitleRetryPolicy", () => { it("caps the cosmetic title retry below the full agent-turn budget", () => {