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
+ // `` 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;
+
+function fakeTitleStream(responses: AssistantMessage[]): {
+ streamFn: NonNullable[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[0]["streamFn"]>;
+ return { streamFn, capturedOptions: () => captured };
+}
+
+const OK_TITLE = {
+ role: "assistant",
+ content: [{ type: "text", text: "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", () => {