From f08e6397db82b6149a558f17f097e6e2737c80dd Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 30 May 2026 16:55:54 +0200 Subject: [PATCH] test: added tiny-model role-propagation and title routing tests - Added `get(path)` support in test `createSettings` to return tiny model setting for `providers.tinyModel`. - Updated `createSettings` in tests to accept optional `tinyModel` with default `"online"`. - Added `flushMicrotasks()` and helper factories for test settings, registry stubs, and online model mocks. - Added tests for `raceFirstNonNull`, `generateSessionTitle` routing, and providers tinyModel enum validation. --- .../role-thinking-helper-propagation.test.ts | 4 + .../test/system-prompt-templates.test.ts | 1 - .../test/tiny-title-generator.test.ts | 240 ++++++++++++++++++ .../coding-agent/test/title-generator.test.ts | 6 +- 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/tiny-title-generator.test.ts 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 718ee0849..37e4b7b54 100644 --- a/packages/coding-agent/test/role-thinking-helper-propagation.test.ts +++ b/packages/coding-agent/test/role-thinking-helper-propagation.test.ts @@ -12,6 +12,10 @@ function getModelOrThrow(id: string) { function createSettings(modelRoles: Record) { return { + get(path: string) { + if (path === "providers.tinyModel") return "online"; + return undefined; + }, getModelRole(role: string) { return modelRoles[role]; }, diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index 90cd475ba..9734c0326 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -229,7 +229,6 @@ describe("system Handlebars prompt templates", () => { expect(omitted.systemPrompt.join("\n\n")).not.toContain("memory://root"); }); - test("buildSystemPrompt keeps system and project as separate ordered blocks with date context in project", async () => { await withTempDir(async dir => { const { systemPrompt } = await buildSystemPrompt({ diff --git a/packages/coding-agent/test/tiny-title-generator.test.ts b/packages/coding-agent/test/tiny-title-generator.test.ts new file mode 100644 index 000000000..0fa8c939c --- /dev/null +++ b/packages/coding-agent/test/tiny-title-generator.test.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as ai from "@oh-my-pi/pi-ai"; +import { type Api, type AssistantMessage, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import { getEnumValues, getUi } from "../src/config/settings-schema"; +import { TINY_TITLE_MODEL_OPTIONS, TINY_TITLE_MODEL_VALUES } from "../src/title/tiny-models"; +import { tinyTitleClient } from "../src/title/tiny-title-client"; +import { generateSessionTitle, raceFirstNonNull, TITLE_LOCAL_FALLBACK_DELAY_MS } from "../src/utils/title-generator"; + +async function flushMicrotasks(turns = 4): Promise { + for (let i = 0; i < turns; i += 1) await Promise.resolve(); +} + +function getModelOrThrow(id: string): Model { + const model = getBundledModel("anthropic", id); + if (!model) throw new Error(`Expected model ${id}`); + return model; +} + +function createSettings(model: Model, tinyModel: string) { + return { + get(path: string) { + if (path === "providers.tinyModel") return tinyModel; + return undefined; + }, + getModelRole(role: string) { + return role === "smol" ? `${model.provider}/${model.id}` : undefined; + }, + getStorage() { + return undefined; + }, + } as never; +} + +function createRegistry(model: Model) { + return { + getAvailable: () => [model], + getApiKey: async () => "test-key", + } as never; +} + +function mockOnlineTitle(title: string | null) { + return vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: title + ? [ + { + type: "toolCall", + id: "call-title", + name: "set_title", + arguments: { title }, + }, + ] + : [{ type: "text", text: "" }], + } as never); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("raceFirstNonNull", () => { + it("resolves with local result without starting fallback", async () => { + let fallbackStarted = false; + const title = await raceFirstNonNull( + Promise.resolve("Local Title"), + () => { + fallbackStarted = true; + return Promise.resolve("Online Title"); + }, + TITLE_LOCAL_FALLBACK_DELAY_MS, + ); + + expect(title).toBe("Local Title"); + expect(fallbackStarted).toBe(false); + }); + + it("starts fallback after the hardcoded delay", async () => { + vi.useFakeTimers(); + const local = Promise.withResolvers(); + let fallbackStarted = false; + const result = raceFirstNonNull( + local.promise, + () => { + fallbackStarted = true; + return Promise.resolve("Online Title"); + }, + TITLE_LOCAL_FALLBACK_DELAY_MS, + ); + + await flushMicrotasks(); + expect(fallbackStarted).toBe(false); + vi.advanceTimersByTime(TITLE_LOCAL_FALLBACK_DELAY_MS - 1); + await flushMicrotasks(); + expect(fallbackStarted).toBe(false); + vi.advanceTimersByTime(1); + await flushMicrotasks(); + + expect(fallbackStarted).toBe(true); + await expect(result).resolves.toBe("Online Title"); + local.resolve(null); + }); + + it("starts fallback immediately when local fails", async () => { + let fallbackStarted = false; + const title = await raceFirstNonNull( + Promise.reject(new Error("local failed")), + () => { + fallbackStarted = true; + return Promise.resolve("Online Title"); + }, + TITLE_LOCAL_FALLBACK_DELAY_MS, + ); + + expect(title).toBe("Online Title"); + expect(fallbackStarted).toBe(true); + }); + + it("returns null only after local and fallback return null", async () => { + const title = await raceFirstNonNull( + Promise.resolve(null), + () => Promise.resolve(null), + TITLE_LOCAL_FALLBACK_DELAY_MS, + ); + + expect(title).toBeNull(); + }); + + it("runs the loser-cancel callback when local wins after fallback starts", async () => { + vi.useFakeTimers(); + const local = Promise.withResolvers(); + const fallback = Promise.withResolvers(); + let fallbackStarted = false; + let cancelCount = 0; + const result = raceFirstNonNull( + local.promise, + () => { + fallbackStarted = true; + return fallback.promise; + }, + TITLE_LOCAL_FALLBACK_DELAY_MS, + () => { + cancelCount += 1; + }, + ); + + vi.advanceTimersByTime(TITLE_LOCAL_FALLBACK_DELAY_MS); + await flushMicrotasks(); + expect(fallbackStarted).toBe(true); + + local.resolve("Local Title"); + await expect(result).resolves.toBe("Local Title"); + expect(cancelCount).toBe(1); + fallback.resolve(null); + }); +}); + +describe("tiny title generator routing", () => { + it("keeps online-only behavior when Tiny Model is Online", async () => { + const model = getModelOrThrow("claude-sonnet-4-5"); + const local = vi.spyOn(tinyTitleClient, "generate").mockResolvedValue("Local Title"); + const online = mockOnlineTitle("Online Title"); + + const title = await generateSessionTitle( + "Investigate routing", + createRegistry(model), + createSettings(model, "online"), + ); + + expect(title).toBe("Online Title"); + expect(local).not.toHaveBeenCalled(); + expect(online).toHaveBeenCalledTimes(1); + }); + + it("uses the local client for selected local models", async () => { + const model = getModelOrThrow("claude-sonnet-4-5"); + const local = vi.spyOn(tinyTitleClient, "generate").mockResolvedValue("Local Title"); + const online = mockOnlineTitle("Online Title"); + + const title = await generateSessionTitle( + "Investigate routing", + createRegistry(model), + createSettings(model, "lfm2-350m"), + ); + + expect(title).toBe("Local Title"); + expect(local).toHaveBeenCalledWith("lfm2-350m", "Investigate routing"); + expect(online).not.toHaveBeenCalled(); + }); + + it("starts online fallback immediately when local returns null", async () => { + const model = getModelOrThrow("claude-sonnet-4-5"); + vi.spyOn(tinyTitleClient, "generate").mockResolvedValue(null); + const online = mockOnlineTitle("Online Title"); + + const title = await generateSessionTitle( + "Investigate fallback", + createRegistry(model), + createSettings(model, "lfm2-350m"), + ); + + expect(title).toBe("Online Title"); + expect(online).toHaveBeenCalledTimes(1); + }); + + it("aborts the online request when delayed local generation wins", async () => { + vi.useFakeTimers(); + const model = getModelOrThrow("claude-sonnet-4-5"); + const local = Promise.withResolvers(); + const onlineHold = Promise.withResolvers(); + let onlineSignal: AbortSignal | undefined; + vi.spyOn(tinyTitleClient, "generate").mockReturnValue(local.promise); + vi.spyOn(ai, "completeSimple").mockImplementation((_model, _context, options) => { + onlineSignal = options?.signal; + return onlineHold.promise; + }); + + const result = generateSessionTitle( + "Investigate cancellation", + createRegistry(model), + createSettings(model, "lfm2-350m"), + ); + + vi.advanceTimersByTime(TITLE_LOCAL_FALLBACK_DELAY_MS); + await flushMicrotasks(); + expect(onlineSignal?.aborted).toBe(false); + + local.resolve("Local Title"); + await expect(result).resolves.toBe("Local Title"); + expect(onlineSignal?.aborted).toBe(true); + onlineHold.resolve({ stopReason: "abort", content: [] } as never); + }); +}); + +describe("providers.tinyModel schema", () => { + it("keeps enum values and UI options in sync with the tiny model registry", () => { + expect(getEnumValues("providers.tinyModel")).toEqual([...TINY_TITLE_MODEL_VALUES]); + expect(getUi("providers.tinyModel")?.options).toEqual(TINY_TITLE_MODEL_OPTIONS); + }); +}); diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index 845a1669b..fd2f2350a 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -9,8 +9,12 @@ function getModelOrThrow(id: string): Model { return model; } -function createSettings(model: Model) { +function createSettings(model: Model, tinyModel = "online") { return { + get(path: string) { + if (path === "providers.tinyModel") return tinyModel; + return undefined; + }, getModelRole(role: string) { return role === "smol" ? `${model.provider}/${model.id}` : undefined; },