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:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user