From 007a0108b9c810fe892fa8e3e80c89de08634287 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 5 Jul 2026 13:20:48 +0200 Subject: [PATCH] test(coding-agent): cover reasoning-safe helper budgets --- .../test/auto-thinking-classifier.test.ts | 40 +++++++++++++++ .../role-thinking-helper-propagation.test.ts | 22 ++++++++ .../test/tts/speech-enhancer.test.ts | 48 +++++++++++++++++- .../test/unexpected-stop-classifier.test.ts | 50 ++++++++++++++++++- 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index c7ff9cfc4..912b1e6d8 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import * as ai from "@oh-my-pi/pi-ai"; import { Effort, type Model } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { @@ -134,6 +135,45 @@ describe("auto thinking classifier helpers", () => { } }); + it("uses a reasoning-safe online classifier budget when the catalog disables reasoning", async () => { + const baseModel = getBundledModel("anthropic", "claude-sonnet-4-6"); + if (!baseModel) throw new Error("Expected bundled Claude Sonnet 4.6 model"); + const classifierModel = { ...baseModel, reasoning: false }; + const settings = { + get(path: string) { + if (path === "providers.autoThinkingModel") return "online"; + return undefined; + }, + getModelRole(role: string) { + return role === "smol" ? `${classifierModel.provider}/${classifierModel.id}` : undefined; + }, + getStorage() { + return undefined; + }, + } as never; + const registry = { + getAvailable: () => [classifierModel], + getApiKey: async () => "test-key", + resolver: () => async () => "test-key", + } as never; + const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "high" }], + } as never); + + const effort = await classifyDifficulty("add validation around the retry path", { + settings, + registry, + model: baseModel, + }); + const options = completeSimpleMock.mock.calls[0]?.[2] as + | { disableReasoning?: boolean; maxTokens?: number } + | undefined; + + expect(effort).toBe(Effort.High); + expect(options).toMatchObject({ disableReasoning: true, maxTokens: 1024 }); + }); + it("clamps auto effort to model support while never resolving below low", () => { const model = getBundledModel("anthropic", "claude-sonnet-4-6"); if (!model) throw new Error("Expected bundled Claude Sonnet 4.6 model"); diff --git a/packages/coding-agent/test/role-thinking-helper-propagation.test.ts b/packages/coding-agent/test/role-thinking-helper-propagation.test.ts index d04c8d084..4add7cba0 100644 --- a/packages/coding-agent/test/role-thinking-helper-propagation.test.ts +++ b/packages/coding-agent/test/role-thinking-helper-propagation.test.ts @@ -59,6 +59,28 @@ describe("role thinking helper propagation", () => { }); }); + it("keeps the commit budget reasoning-safe when the catalog disables reasoning", async () => { + const model = { ...getModelOrThrow("claude-sonnet-4-5"), reasoning: false }; + const settings = createSettings({ + smol: `${model.provider}/${model.id}`, + }); + const registry = { + getAvailable: () => [model], + getApiKey: async () => "test-key", + resolver: vi.fn(() => async () => "test-key"), + }; + const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "end_turn", + content: [{ type: "text", text: "fix qwen title budget" }], + } as never); + + const message = await generateCommitMessage(`diff --git a/x b/x\n+change\n`, registry as never, settings); + expect(message).toBe("fix qwen title budget"); + expect(completeSimpleMock.mock.calls[0]?.[2]).toMatchObject({ + maxTokens: 1024, + }); + }); + it("disables reasoning for title generation even when smol role has thinking", async () => { const model = getModelOrThrow("claude-sonnet-4-5"); const settings = createSettings({ diff --git a/packages/coding-agent/test/tts/speech-enhancer.test.ts b/packages/coding-agent/test/tts/speech-enhancer.test.ts index e6759f5b2..523433f1d 100644 --- a/packages/coding-agent/test/tts/speech-enhancer.test.ts +++ b/packages/coding-agent/test/tts/speech-enhancer.test.ts @@ -1,5 +1,49 @@ -import { describe, expect, it } from "bun:test"; -import { BlockAccumulator } from "@oh-my-pi/pi-coding-agent/tts/speech-enhancer"; +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as ai from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { BlockAccumulator, SpeechEnhancer } from "@oh-my-pi/pi-coding-agent/tts/speech-enhancer"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("SpeechEnhancer rewriting", () => { + it("uses a reasoning-safe rewrite budget when the catalog disables reasoning", async () => { + const baseModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!baseModel) throw new Error("Expected bundled Claude Sonnet 4.5 model"); + const model = { ...baseModel, reasoning: false }; + const settings = { + get() { + return undefined; + }, + getModelRole(role: string) { + return role === "tiny" ? `${model.provider}/${model.id}` : undefined; + }, + getStorage() { + return undefined; + }, + } as never; + const registry = { + getAvailable: () => [model], + getApiKey: async () => "test-key", + resolver: () => async () => "test-key", + } as never; + const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "Spoken text" }], + } as never); + + const rewritten = await new SpeechEnhancer({ settings, registry, sessionId: "session-1" }).rewrite( + "**Spoken text**", + ); + const options = completeSimpleMock.mock.calls[0]?.[2] as + | { disableReasoning?: boolean; maxTokens?: number } + | undefined; + + expect(rewritten).toBe("Spoken text"); + expect(options).toMatchObject({ disableReasoning: true, maxTokens: 1536 }); + }); +}); /** Push each delta in order; returns blocks completed by pushes plus the flush tail. */ function feed(...deltas: string[]): { blocks: string[]; tail: string | null } { diff --git a/packages/coding-agent/test/unexpected-stop-classifier.test.ts b/packages/coding-agent/test/unexpected-stop-classifier.test.ts index 8b03714de..26c988f71 100644 --- a/packages/coding-agent/test/unexpected-stop-classifier.test.ts +++ b/packages/coding-agent/test/unexpected-stop-classifier.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import * as ai from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { + classifyUnexpectedStop, isUnexpectedStopCandidate, parseUnexpectedStopClassification, } from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier"; @@ -20,6 +23,10 @@ function makeAssistantMessage(options: { } as unknown as AssistantMessage; } +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("isUnexpectedStopCandidate", () => { it("returns true for a text-only stop", () => { const message = makeAssistantMessage({ @@ -71,6 +78,47 @@ describe("isUnexpectedStopCandidate", () => { }); }); +describe("classifyUnexpectedStop", () => { + it("uses a reasoning-safe online classifier budget when the catalog disables reasoning", async () => { + const baseModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!baseModel) throw new Error("Expected bundled Claude Sonnet 4.5 model"); + const model = { ...baseModel, reasoning: false }; + const settings = { + get(path: string) { + if (path === "providers.unexpectedStopModel") return "online"; + return undefined; + }, + getModelRole(role: string) { + return role === "smol" ? `${model.provider}/${model.id}` : undefined; + }, + getStorage() { + return undefined; + }, + } as never; + const registry = { + getAvailable: () => [model], + getApiKey: async () => "test-key", + resolver: () => async () => "test-key", + } as never; + const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "YES" }], + } as never); + + const result = await classifyUnexpectedStop("I will continue with the next command.", { + settings, + registry, + sessionId: "session-1", + }); + const options = completeSimpleMock.mock.calls[0]?.[2] as + | { disableReasoning?: boolean; maxTokens?: number } + | undefined; + + expect(result).toBe(true); + expect(options).toMatchObject({ disableReasoning: true, maxTokens: 1024 }); + }); +}); + describe("parseUnexpectedStopClassification", () => { it("returns true for YES output", () => { expect(parseUnexpectedStopClassification("YES")).toBe(true);