From 6cd077e075ae562c2fe1fbbb2ab3572a0b3db595 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 10 Apr 2026 22:04:30 +0200 Subject: [PATCH] feat(coding-agent): added designer role with model fallback support and Gemini 3.1 - Added designer model role for UI/UX design tasks with Gemini 3.1 Pro as default model. - Implemented model role fallback list support enabling automatic fallback to next available model when primary is unavailable. - Refactored model role resolution to support multiple fallback patterns per role with thinking level mapping. - Updated designer agent to use pi/designer role alias instead of explicit model list, removing spawns configuration. - Added test coverage for model resolver fallback patterns and designer role override preferences. - Added test coverage for geminiImageTool X-Title header routing through OpenRouter. --- packages/coding-agent/CHANGELOG.md | 5 ++ .../coding-agent/src/config/model-registry.ts | 5 +- .../coding-agent/src/config/model-resolver.ts | 56 +++++++++------- packages/coding-agent/src/priority.json | 8 +++ .../src/prompts/agents/designer.md | 3 +- .../coding-agent/test/model-resolver.test.ts | 38 +++++++++++ .../test/tools/gemini-image.test.ts | 66 +++++++++++++++++++ 7 files changed, 153 insertions(+), 28 deletions(-) create mode 100644 packages/coding-agent/test/tools/gemini-image.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 68c84804a..d7fa343ff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,14 +1,19 @@ # Changelog ## [Unreleased] + ### Added +- Added `designer` model role for UI/UX design tasks with Gemini 3.1 Pro as default model +- Added support for model role fallback lists — roles can now resolve to multiple model patterns with automatic fallback to next available model - Added `extractReadableFromHtml` utility function to extract readable content from HTML with Readability article extraction and CSS selector fallback - Added support for GFM (GitHub Flavored Markdown) features including tables, strikethrough, and task lists in HTML-to-markdown conversion - Added `resolveDiagnosticTargets` utility function to handle glob pattern resolution with fallback to literal file paths for bracket-style paths ### Changed +- Updated designer agent to use `pi/designer` role alias instead of explicit model list +- Refactored model role resolution to support multiple fallback patterns per role, improving model availability handling - Replaced regex-based HTML-to-markdown conversion with Turndown library and GFM plugin for more accurate formatting of complex HTML structures - Simplified no-changes response to omit redundant response text when chunk content already matches - Clarified region suffix behavior on leaf and compound statement chunks — `~` and `^` now fall back to whole-chunk replacement with explicit guidance to supply complete structural content diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 210d639fc..19b776d5f 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -39,7 +39,7 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st return Boolean(apiKey) && apiKey !== kNoAuth; } -export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "commit" | "task"; +export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task"; export interface ModelRoleInfo { tag?: string; @@ -53,11 +53,12 @@ export const MODEL_ROLES: Record = { slow: { tag: "SLOW", name: "Thinking", color: "accent" }, vision: { tag: "VISION", name: "Vision", color: "error" }, plan: { tag: "PLAN", name: "Architect", color: "muted" }, + designer: { tag: "DESIGNER", name: "Designer", color: "muted" }, commit: { tag: "COMMIT", name: "Commit", color: "dim" }, task: { tag: "TASK", name: "Subtask", color: "muted" }, }; -export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "commit", "task"]; +export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"]; /** Alias for ModelRoleInfo - used for both built-in and custom roles */ export type RoleInfo = ModelRoleInfo; diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index c9e7f2ff5..1dd36a45d 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -387,7 +387,7 @@ function isSessionInheritedAgentPattern(value: string): boolean { return value === DEFAULT_MODEL_ROLE || value === `${PREFIX_MODEL_ROLE}${DEFAULT_MODEL_ROLE}` || value === "pi/task"; } -function resolveConfiguredRolePattern(value: string, settings?: Settings): string | undefined { +function resolveConfiguredRolePattern(value: string, settings?: Settings): string[] | undefined { const normalized = value.trim(); if (!normalized) return undefined; @@ -396,11 +396,16 @@ function resolveConfiguredRolePattern(value: string, settings?: Settings): strin lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined; const aliasCandidate = thinkingLevel ? normalized.slice(0, lastColonIndex) : normalized; const role = getModelRoleAlias(aliasCandidate); - if (!role) return normalized; + if (!role) return [normalized]; const configured = settings?.getModelRole(role)?.trim(); - if (!configured) return undefined; - return thinkingLevel ? `${configured}:${thinkingLevel}` : configured; + const roleDefaults = normalizeModelPatternList(MODEL_PRIO[role as keyof typeof MODEL_PRIO]); + const resolved = configured ? normalizeModelPatternList(configured) : roleDefaults; + if (!resolved || resolved.length === 0) { + return undefined; + } + + return thinkingLevel ? resolved.map(pattern => `${pattern}:${thinkingLevel}`) : resolved; } /** @@ -412,7 +417,7 @@ export function expandRoleAlias(value: string, settings?: Settings): string { return settings?.getModelRole("default") ?? value; } - const resolved = resolveConfiguredRolePattern(value, settings); + const resolved = resolveConfiguredRolePattern(value, settings)?.[0]; return resolved ?? value; } @@ -420,10 +425,9 @@ export function resolveConfiguredModelPatterns(value: string | string[] | undefi const patterns = normalizeModelPatternList(value); return patterns.flatMap(pattern => { const resolved = resolveConfiguredRolePattern(pattern, settings); - return resolved ? [resolved] : []; + return resolved ?? []; }); } - export interface AgentModelPatternResolutionOptions { settingsOverride?: string | string[]; agentModel?: string | string[]; @@ -479,26 +483,30 @@ export function resolveModelRoleValue( const lastColonIndex = normalized.lastIndexOf(":"); const thinkingSelector = lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined; - const aliasCandidate = thinkingSelector ? normalized.slice(0, lastColonIndex) : normalized; - const effectivePattern = resolveConfiguredRolePattern(aliasCandidate, options?.settings); - if (!effectivePattern) { + const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings); + if (!effectivePatterns || effectivePatterns.length === 0) { return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined }; } - const patternWithSuffix = thinkingSelector ? `${effectivePattern}:${thinkingSelector}` : effectivePattern; - const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPattern( - patternWithSuffix, - availableModels, - options?.matchPreferences, - ); - return { - model, - thinkingLevel: explicitThinkingLevel - ? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel) - : thinkingLevel, - explicitThinkingLevel, - warning, - }; + let warning: string | undefined; + for (const effectivePattern of effectivePatterns) { + const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences); + if (resolved.model) { + return { + model: resolved.model, + thinkingLevel: resolved.explicitThinkingLevel + ? (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel) + : resolved.thinkingLevel, + explicitThinkingLevel: resolved.explicitThinkingLevel, + warning: resolved.warning, + }; + } + if (!warning && resolved.warning) { + warning = resolved.warning; + } + } + + return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning }; } export function extractExplicitThinkingSelector( diff --git a/packages/coding-agent/src/priority.json b/packages/coding-agent/src/priority.json index ff0ce97fa..67a5021f4 100644 --- a/packages/coding-agent/src/priority.json +++ b/packages/coding-agent/src/priority.json @@ -25,5 +25,13 @@ "opus-4.1", "opus-4-1", "pro" + ], + "designer": [ + "google-gemini-cli/gemini-3.1-pro", + "google-gemini-cli/gemini-3-pro", + "gemini-3.1-pro", + "gemini-3-1-pro", + "gemini-3-pro", + "gemini-3" ] } diff --git a/packages/coding-agent/src/prompts/agents/designer.md b/packages/coding-agent/src/prompts/agents/designer.md index 5486f53ba..2f93b5435 100644 --- a/packages/coding-agent/src/prompts/agents/designer.md +++ b/packages/coding-agent/src/prompts/agents/designer.md @@ -1,8 +1,7 @@ --- name: designer description: UI/UX specialist for design implementation, review, visual refinement -spawns: explore -model: google-gemini-cli/gemini-3.1-pro, google-gemini-cli/gemini-3-pro, gemini-3.1-pro, gemini-3-1-pro, gemini-3-pro, gemini-3, pi/default +model: pi/designer --- You are an expert UI/UX designer implementing and reviewing UI designs. diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 41ed6b689..f46a5d299 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -403,6 +403,44 @@ describe("resolveAgentModelPatterns", () => { expect(result).toEqual(["anthropic/claude-sonnet-4-5:high"]); }); + + test("expands pi/designer to priority defaults", () => { + const settings = Settings.isolated({ + modelRoles: { + default: "anthropic/claude-sonnet-4-5", + }, + }); + + const result = resolveAgentModelPatterns({ + agentModel: "pi/designer", + settings, + }); + + expect(result).toEqual([ + "google-gemini-cli/gemini-3.1-pro", + "google-gemini-cli/gemini-3-pro", + "gemini-3.1-pro", + "gemini-3-1-pro", + "gemini-3-pro", + "gemini-3", + ]); + }); + + test("prefers configured designer role override over priority defaults", () => { + const settings = Settings.isolated({ + modelRoles: { + default: "anthropic/claude-sonnet-4-5", + designer: "openai/gpt-4o", + }, + }); + + const result = resolveAgentModelPatterns({ + agentModel: "pi/designer", + settings, + }); + + expect(result).toEqual(["openai/gpt-4o"]); + }); }); describe("resolveModelFromString", () => { diff --git a/packages/coding-agent/test/tools/gemini-image.test.ts b/packages/coding-agent/test/tools/gemini-image.test.ts new file mode 100644 index 000000000..c981b6b59 --- /dev/null +++ b/packages/coding-agent/test/tools/gemini-image.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { geminiImageTool } from "@oh-my-pi/pi-coding-agent/tools/gemini-image"; + +const originalFetch = global.fetch; +const originalOpenRouterKey = Bun.env.OPENROUTER_API_KEY; + +afterEach(() => { + global.fetch = originalFetch; + if (originalOpenRouterKey === undefined) { + delete Bun.env.OPENROUTER_API_KEY; + } else { + Bun.env.OPENROUTER_API_KEY = originalOpenRouterKey; + } +}); + +function getHeaderValue(headers: RequestInit["headers"] | undefined, name: string): string | undefined { + if (!headers) return undefined; + if (headers instanceof Headers) { + return headers.get(name) ?? headers.get(name.toLowerCase()) ?? headers.get(name.toUpperCase()); + } + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + if (key.toLowerCase() === name.toLowerCase()) { + return value; + } + } + return undefined; + } + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name.toLowerCase()) { + return value; + } + } + return undefined; +} + +describe("geminiImageTool", () => { + it("sets X-Title when routing image generation through OpenRouter", async () => { + let requestHeaders: RequestInit["headers"] | undefined; + Bun.env.OPENROUTER_API_KEY = "test-openrouter-key"; + + const fetchMock: typeof fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + requestHeaders = init?.headers; + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + fetchMock.preconnect = originalFetch.preconnect; + global.fetch = fetchMock; + + const ctx = { + sessionManager: { + getCwd: () => "/tmp", + }, + modelRegistry: { + getApiKeyForProvider: async () => undefined, + }, + } as unknown as ToolSession; + + const result = await geminiImageTool.execute("call-1", { subject: "a cat" }, undefined, ctx); + expect(result.content[0].type).toBe("text"); + expect(getHeaderValue(requestHeaders, "X-Title")).toBe("Oh-My-Pi"); + }); +});