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.
This commit is contained in:
can1357
2026-04-10 22:07:02 +02:00
parent 11e8fd4902
commit 6cd077e075
7 changed files with 153 additions and 28 deletions
+5
View File
@@ -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
@@ -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<ModelRole, ModelRoleInfo> = {
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;
@@ -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(
+8
View File
@@ -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"
]
}
@@ -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.
@@ -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", () => {
@@ -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");
});
});