Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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 `<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 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
Expand Down
39 changes: 37 additions & 2 deletions packages/coding-agent/src/core/session-title-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down
111 changes: 110 additions & 1 deletion packages/coding-agent/test/session-title-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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</title>" }],
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 `<title>` 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", () => {
Expand Down