Skip to content

Commit f0ebff9

Browse files
committed
fix(naming): force propose_name when the request serializes thinking off
Claude Haiku 4.5 with thinking off skipped the propose_name tool about 30% of the time, so naming fell through to the next hardcoded candidate and left the configured Claude route. Force the tool choice when the built provider options leave thinking off (Anthropic: no thinking block or disabled; OpenAI: reasoningEffort none) and keep the unforced request when thinking is on.
1 parent 8e0dded commit f0ebff9

2 files changed

Lines changed: 102 additions & 9 deletions

File tree

‎src/node/services/workspaceTitleGenerator.test.ts‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { AIService } from "./aiService";
1212
import type { SendMessageError } from "@/common/types/errors";
1313
import { NAME_GEN_MAX_OUTPUT_TOKENS } from "@/common/constants/nameGeneration";
1414
import type { ThinkingLevel } from "@/common/types/thinking";
15+
import type { MuxProviderOptions } from "@/common/types/providerOptions";
1516
import { attachLanguageModelCleanup } from "./languageModelCleanup";
1617

1718
afterEach(() => {
@@ -85,7 +86,8 @@ function createTitleModel(modelId = "title-model"): LanguageModel {
8586
function pinnedOptionsFor(
8687
modelString: string,
8788
model: LanguageModel,
88-
_opts?: { thinkingLevel?: ThinkingLevel; agentInitiated?: boolean }
89+
_opts?: { thinkingLevel?: ThinkingLevel; agentInitiated?: boolean },
90+
muxProviderOptions: MuxProviderOptions = {}
8991
) {
9092
return Ok({
9193
model,
@@ -94,7 +96,7 @@ function pinnedOptionsFor(
9496
metadataModel: modelString,
9597
optionsModelString: modelString,
9698
optionsProvidersConfig: {},
97-
optionsMuxProviderOptions: {},
99+
optionsMuxProviderOptions: muxProviderOptions,
98100
});
99101
}
100102

@@ -374,6 +376,82 @@ describe("generateWorkspaceIdentity candidate settings", () => {
374376
}
375377
);
376378

379+
const forcedProposeName = { type: "tool", toolName: "propose_name" } as const;
380+
test.each([
381+
// Fallback default "off" serializes no Anthropic thinking block.
382+
{ name: "Haiku default", model: "anthropic:claude-haiku-4-5", forced: true },
383+
{
384+
name: "Haiku off",
385+
model: "anthropic:claude-haiku-4-5",
386+
thinkingLevel: "off" as const,
387+
forced: true,
388+
},
389+
// Luna "off" serializes reasoningEffort "none".
390+
{ name: "Luna default", model: "openai:gpt-6-luna", forced: true },
391+
// Chat Completions pins Luna to effort "none" at any level.
392+
{
393+
name: "Luna high on Chat Completions",
394+
model: "openai:gpt-6-luna",
395+
thinkingLevel: "high" as const,
396+
chat: true,
397+
forced: true,
398+
},
399+
{
400+
name: "Haiku medium",
401+
model: "anthropic:claude-haiku-4-5",
402+
thinkingLevel: "medium" as const,
403+
forced: false,
404+
},
405+
// The per-model floor turns thinking on.
406+
{
407+
name: "Haiku off with low floor",
408+
model: "anthropic:claude-haiku-4-5",
409+
thinkingLevel: "off" as const,
410+
minThinkingLevel: "low" as const,
411+
forced: false,
412+
},
413+
// Policy keeps "off", but this route serializes adaptive thinking.
414+
{
415+
name: "Sonnet 5.5 off",
416+
model: "anthropic:claude-sonnet-5-5",
417+
thinkingLevel: "off" as const,
418+
forced: false,
419+
},
420+
{
421+
name: "Luna off with high floor",
422+
model: "openai:gpt-6-luna",
423+
thinkingLevel: "off" as const,
424+
minThinkingLevel: "high" as const,
425+
forced: false,
426+
},
427+
])(
428+
"forces propose_name only when the request serializes thinking off ($name)",
429+
async ({ model, thinkingLevel, minThinkingLevel, chat, forced }) => {
430+
const aiService = titleAIService((modelString) =>
431+
Promise.resolve(
432+
pinnedOptionsFor(
433+
modelString,
434+
createTitleModel(modelString),
435+
undefined,
436+
chat ? { openai: { wireFormat: "chatCompletions" } } : {}
437+
)
438+
)
439+
);
440+
const streamTextSpy = spyOn(aiSdk, "streamText").mockReturnValue(proposeNameStream);
441+
442+
const result = await generateWorkspaceIdentity(
443+
"Add setting",
444+
[{ model, thinkingLevel, minThinkingLevel }],
445+
aiService
446+
);
447+
448+
expect(result.success).toBe(true);
449+
expect(streamTextSpy.mock.calls[0]?.[0].toolChoice).toEqual(
450+
forced ? forcedProposeName : undefined
451+
);
452+
}
453+
);
454+
377455
test("skips a candidate whose model cannot be created and reports the last failure", async () => {
378456
const createModelWithPinnedOptions = mock((modelString: string) =>
379457
Promise.resolve(

‎src/node/services/workspaceTitleGenerator.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,9 @@ export async function generateWorkspaceIdentity(
293293
// streamText (not generateText): the Codex OAuth endpoint requires
294294
// stream:true in the request body; streamText sets it automatically.
295295
//
296-
// No toolChoice — forced tool choice (toolChoice: "required" / "any" /
297-
// { type: "tool" }) is incompatible with extended thinking models.
298-
// Instead, the prompt instructs the model to call the tool, and the
299-
// name_workspace builtin agent declares tools.require: [propose_name]
300-
// which the StreamManager enforces via stopWhen for full agent sessions.
301-
// For this direct streamText path, the candidate retry loop handles the
302-
// (rare) case where the model ignores the instruction.
296+
// Force propose_name only when the serialized request leaves thinking off:
297+
// Anthropic rejects forced tool use with thinking enabled. With thinking on,
298+
// the prompt asks for the call and the candidate loop covers a skipped call.
303299
const currentStream = streamText({
304300
model: pinned.model,
305301
prompt: buildWorkspaceIdentityPrompt(message, conversationContext, latestUserMessage),
@@ -309,6 +305,9 @@ export async function generateWorkspaceIdentity(
309305
...(anthropicBudget !== undefined && {
310306
maxOutputTokens: anthropicBudget + NAME_GEN_MAX_OUTPUT_TOKENS,
311307
}),
308+
...(serializesThinkingOff(providerOptions) && {
309+
toolChoice: { type: "tool", toolName: "propose_name" } as const,
310+
}),
312311
tools: {
313312
// Defined inline so TypeScript preserves full schema inference on
314313
// toolResult.output (the propose_name tool is only used here).
@@ -399,6 +398,22 @@ function anthropicThinkingBudget(
399398
return thinking?.type === "enabled" ? thinking.budgetTokens : undefined;
400399
}
401400

401+
/**
402+
* Whether the serialized request leaves thinking off. An omitted Anthropic
403+
* `thinking` is off because the policy keeps "off" away from models whose API
404+
* defaults to adaptive; an omitted OpenAI effort runs the model's default reasoning.
405+
*/
406+
function serializesThinkingOff(providerOptions: ReturnType<typeof buildProviderOptions>): boolean {
407+
if ("anthropic" in providerOptions) {
408+
const thinking = providerOptions.anthropic.thinking;
409+
return thinking === undefined || thinking.type === "disabled";
410+
}
411+
if ("openai" in providerOptions) {
412+
return providerOptions.openai.reasoningEffort === "none";
413+
}
414+
return false;
415+
}
416+
402417
/**
403418
* Sanitize a string to be git-safe: lowercase, hyphens only, no leading/trailing hyphens.
404419
*/

0 commit comments

Comments
 (0)