feat: improve Claude agent model normalization and tool mapping

This commit is contained in:
Mohamed Boudra
2026-02-13 00:00:57 +07:00
parent 6d4d74fa3a
commit 837d0fb387
10 changed files with 601 additions and 33 deletions
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import { normalizeClaudeRuntimeModelId } from "./claude-agent.js";
describe("normalizeClaudeRuntimeModelId", () => {
const supportedModelIds = new Set(["default", "opus", "haiku"]);
test("maps runtime Sonnet IDs to default alias", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-sonnet-4-5-20250929",
supportedModelIds,
});
expect(normalized).toBe("default");
});
test("prefers alias even when runtime versioned Sonnet ID appears in supported list", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-sonnet-4-5-20250929",
supportedModelIds: new Set([
"default",
"opus",
"haiku",
"claude-sonnet-4-5-20250929",
]),
});
expect(normalized).toBe("default");
});
test("maps runtime Opus IDs to opus alias", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-opus-4-5-20251101",
supportedModelIds,
});
expect(normalized).toBe("opus");
});
test("maps runtime Haiku IDs to haiku alias", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-haiku-4-5-20251001",
supportedModelIds,
});
expect(normalized).toBe("haiku");
});
test("uses configured model when runtime ID is unknown", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-custom-unknown",
supportedModelIds,
configuredModelId: "opus",
});
expect(normalized).toBe("opus");
});
test("preserves runtime model when mapping is not possible", () => {
const normalized = normalizeClaudeRuntimeModelId({
runtimeModelId: "claude-custom-unknown",
supportedModelIds: new Set(["x", "y"]),
});
expect(normalized).toBe("claude-custom-unknown");
});
});
@@ -91,6 +91,92 @@ function normalizeClaudeModelLabel(model: ModelInfo): string {
return fallback;
}
type NormalizeClaudeRuntimeModelIdOptions = {
runtimeModelId: string;
supportedModelIds: ReadonlySet<string> | null;
configuredModelId?: string | null;
currentModelId?: string | null;
};
function normalizeModelIdCandidate(modelId: string | null | undefined): string | null {
if (typeof modelId !== "string") {
return null;
}
const trimmed = modelId.trim();
return trimmed.length > 0 ? trimmed : null;
}
function pickSupportedModelId(
supportedModelIds: ReadonlySet<string>,
candidate: string | null | undefined
): string | null {
const normalizedCandidate = normalizeModelIdCandidate(candidate);
if (!normalizedCandidate) {
return null;
}
return supportedModelIds.has(normalizedCandidate) ? normalizedCandidate : null;
}
export function normalizeClaudeRuntimeModelId(
options: NormalizeClaudeRuntimeModelIdOptions
): string {
const runtimeModel = options.runtimeModelId.trim();
if (!runtimeModel) {
return runtimeModel;
}
const supportedModelIds = options.supportedModelIds;
if (!supportedModelIds || supportedModelIds.size === 0) {
return runtimeModel;
}
const lowerRuntimeModel = runtimeModel.toLowerCase();
if (lowerRuntimeModel.includes("sonnet")) {
const explicitSonnet = pickSupportedModelId(supportedModelIds, "sonnet");
if (explicitSonnet) {
return explicitSonnet;
}
const defaultAlias = pickSupportedModelId(supportedModelIds, "default");
if (defaultAlias) {
return defaultAlias;
}
}
if (lowerRuntimeModel.includes("opus")) {
const alias = pickSupportedModelId(supportedModelIds, "opus");
if (alias) {
return alias;
}
}
if (lowerRuntimeModel.includes("haiku")) {
const alias = pickSupportedModelId(supportedModelIds, "haiku");
if (alias) {
return alias;
}
}
if (supportedModelIds.has(runtimeModel)) {
return runtimeModel;
}
const configuredModelId = pickSupportedModelId(
supportedModelIds,
options.configuredModelId
);
if (configuredModelId) {
return configuredModelId;
}
const currentModelId = pickSupportedModelId(
supportedModelIds,
options.currentModelId
);
if (currentModelId) {
return currentModelId;
}
return runtimeModel;
}
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -583,6 +669,7 @@ class ClaudeAgentSession implements AgentSession {
private activeTurnPromise: Promise<void> | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null;
private selectableModelIds: Set<string> | null = null;
private activeSidechains = new Map<string, string>();
private compacting = false;
private queryRestartNeeded = false;
@@ -726,13 +813,6 @@ class ClaudeAgentSession implements AgentSession {
queue.end();
};
this.cancelCurrentTurn = requestCancel;
if (this.historyPending && this.persistedHistory.length > 0) {
for (const item of this.persistedHistory) {
queue.push({ type: "timeline", item, provider: "claude" });
}
this.historyPending = false;
this.persistedHistory = [];
}
// Start forwarding events and track the promise so future turns can wait for completion
const forwardPromise = this.forwardPromptEvents(sdkMessage, queue, turnId);
@@ -974,6 +1054,20 @@ class ClaudeAgentSession implements AgentSession {
};
}
private async primeSelectableModelIds(query: Query): Promise<void> {
try {
const models = await query.supportedModels();
const ids = models
.map((model) => model.value?.trim())
.filter((id): id is string => typeof id === "string" && id.length > 0);
this.selectableModelIds = new Set(ids);
this.logger.debug({ modelIds: ids }, "Primed Claude selectable model IDs");
} catch (error) {
this.selectableModelIds = null;
this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs");
}
}
private async ensureQuery(): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
return this.query;
@@ -992,6 +1086,7 @@ class ClaudeAgentSession implements AgentSession {
this.logger.debug({ options }, "claude query");
this.input = input;
this.query = query({ prompt: input, options });
await this.primeSelectableModelIds(this.query);
await this.query.setPermissionMode(this.currentMode);
return this.query;
}
@@ -1469,8 +1564,17 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
// Capture actual model from SDK init message (not just the configured model)
if (message.model) {
this.logger.debug({ model: message.model }, "Captured model from SDK init");
this.lastOptionsModel = message.model;
const normalizedModel = normalizeClaudeRuntimeModelId({
runtimeModelId: message.model,
supportedModelIds: this.selectableModelIds,
configuredModelId: this.config.model ?? null,
currentModelId: this.lastOptionsModel,
});
this.logger.debug(
{ model: message.model, normalizedModel },
"Captured model from SDK init"
);
this.lastOptionsModel = normalizedModel;
// Invalidate cached runtime info so it picks up the new model
this.cachedRuntimeInfo = null;
}
@@ -0,0 +1,209 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import type {
AgentPersistenceHandle,
AgentStreamEvent,
} from "../agent-sdk-types.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
function buildSdkQueryMock() {
const events = [
{
type: "system",
subtype: "init",
session_id: "history-session",
permissionMode: "default",
model: "opus",
},
{
type: "assistant",
message: {
content: LIVE_REPLY_MARKER,
},
},
{
type: "result",
subtype: "success",
usage: {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
},
total_cost_usd: 0,
},
];
let index = 0;
return {
next: vi.fn(async () => {
if (index >= events.length) {
return { done: true, value: undefined };
}
const value = events[index];
index += 1;
return { done: false, value };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
};
}
function collectTimelineText(events: AgentStreamEvent[]): string {
const chunks: string[] = [];
for (const event of events) {
if (event.type !== "timeline") {
continue;
}
if (event.item.type === "user_message") {
chunks.push(event.item.text);
}
if (event.item.type === "assistant_message") {
chunks.push(event.item.text);
}
}
return chunks.join("\n");
}
describe("ClaudeAgentSession history replay regression", () => {
let tempRoot: string;
let cwd: string;
let configDir: string;
let previousClaudeConfigDir: string | undefined;
beforeEach(() => {
sdkMocks.query.mockImplementation(() => buildSdkQueryMock());
tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-"));
cwd = path.join(tempRoot, "repo");
configDir = path.join(tempRoot, "claude-config");
mkdirSync(cwd, { recursive: true });
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const historyDir = path.join(configDir, "projects", sanitized);
mkdirSync(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "history-session.jsonl");
writeFileSync(
historyPath,
[
JSON.stringify({
type: "user",
sessionId: "history-session",
cwd,
message: {
role: "user",
content: HISTORY_USER_MARKER,
},
}),
JSON.stringify({
type: "assistant",
sessionId: "history-session",
cwd,
message: {
role: "assistant",
content: HISTORY_ASSISTANT_MARKER,
},
}),
].join("\n"),
"utf8"
);
previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
});
afterEach(() => {
sdkMocks.query.mockReset();
if (previousClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
}
rmSync(tempRoot, { recursive: true, force: true });
});
test("does not replay persisted history during the first live stream turn", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
nativeHandle: "history-session",
metadata: {
provider: "claude",
cwd,
},
};
const session = await client.resumeSession(handle, { cwd });
const events: AgentStreamEvent[] = [];
try {
for await (const event of session.stream("Say hello")) {
events.push(event);
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
break;
}
}
} finally {
await session.close();
}
const timelineText = collectTimelineText(events);
expect(timelineText).toContain(LIVE_REPLY_MARKER);
expect(timelineText).not.toContain(HISTORY_USER_MARKER);
expect(timelineText).not.toContain(HISTORY_ASSISTANT_MARKER);
});
test("still exposes persisted history through streamHistory", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({ logger });
const handle: AgentPersistenceHandle = {
provider: "claude",
sessionId: "history-session",
nativeHandle: "history-session",
metadata: {
provider: "claude",
cwd,
},
};
const session = await client.resumeSession(handle, { cwd });
const historyEvents: AgentStreamEvent[] = [];
try {
for await (const event of session.streamHistory()) {
historyEvents.push(event);
}
} finally {
await session.close();
}
const timelineText = collectTimelineText(historyEvents);
expect(timelineText).toContain(HISTORY_USER_MARKER);
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
});
});
@@ -244,6 +244,24 @@ describe("claude tool-call mapper", () => {
});
});
it("normalizes namespaced voice MCP speak tools", () => {
const item = expectMapped(
mapClaudeCompletedToolCall({
callId: "claude-speak-2",
name: "mcp__paseo_voice__speak",
input: { text: "Hey! I can hear you." },
output: { ok: true },
})
);
expect(item.name).toBe("speak");
expect(item.detail).toEqual({
type: "unknown",
input: "Hey! I can hear you.",
output: null,
});
});
it("drops tool calls when callId is missing", () => {
const item = mapClaudeCompletedToolCall({
callId: null,
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { isSpeakToolName } from "../../tool-name-normalization.js";
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
type MapperParams = {
@@ -43,6 +44,53 @@ const ClaudeToolCallPass1Schema = ClaudeRawToolCallSchema.transform((raw) => ({
status: raw.status,
}));
const ClaudeShellToolNameSchema = z.union([
z.literal("Bash"),
z.literal("bash"),
z.literal("shell"),
z.literal("exec_command"),
]);
const ClaudeReadToolNameSchema = z.union([
z.literal("Read"),
z.literal("read"),
z.literal("read_file"),
z.literal("view_file"),
]);
const ClaudeWriteToolNameSchema = z.union([
z.literal("Write"),
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
]);
const ClaudeEditToolNameSchema = z.union([
z.literal("Edit"),
z.literal("MultiEdit"),
z.literal("multi_edit"),
z.literal("edit"),
z.literal("apply_patch"),
z.literal("apply_diff"),
z.literal("str_replace_editor"),
]);
const ClaudeSearchToolNameSchema = z.union([
z.literal("WebSearch"),
z.literal("web_search"),
z.literal("search"),
]);
const ClaudeSpeakToolNameSchema = z
.string()
.min(1)
.refine((name) => isSpeakToolName(name.trim()));
const ClaudeToolKindSchema = z.enum([
"shell",
"read",
"write",
"edit",
"search",
"speak",
"unknown",
]);
const ClaudeToolCallPass2BaseSchema = z.object({
callId: z.string().min(1),
name: z.string().min(1),
@@ -51,7 +99,7 @@ const ClaudeToolCallPass2BaseSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
error: z.unknown().nullable(),
status: ClaudeToolCallStatusSchema,
toolKind: z.enum(["speak", "other"]),
toolKind: ClaudeToolKindSchema,
});
const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
@@ -60,26 +108,81 @@ const ClaudeToolCallPass2InputSchema = ClaudeToolCallPass2BaseSchema.omit({
const ClaudeToolCallPass2EnvelopeSchema = z.union([
ClaudeToolCallPass2InputSchema.extend({
name: z.literal("mcp__paseo__speak"),
name: ClaudeShellToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "shell" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeReadToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "read" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeWriteToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "write" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeEditToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "edit" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeSearchToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "search" as const,
})),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeSpeakToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: "speak" as const,
toolKind: "speak" as const,
})),
ClaudeToolCallPass2InputSchema.transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "other" as const,
toolKind: "unknown" as const,
})),
]);
const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"),
name: z.literal("mcp__paseo__speak"),
toolKind: z.literal("shell"),
name: ClaudeShellToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("other"),
toolKind: z.literal("read"),
name: ClaudeReadToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("write"),
name: ClaudeWriteToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("edit"),
name: ClaudeEditToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("search"),
name: ClaudeSearchToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"),
name: z.literal("speak"),
}),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("unknown"),
}),
]);
@@ -87,7 +190,21 @@ type ClaudeToolCallPass2 = z.infer<typeof ClaudeToolCallPass2Schema>;
function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimelineItem {
const name = normalized.toolKind === "speak" ? ("speak" as const) : normalized.name;
const detail = deriveClaudeToolDetail(name, normalized.input, normalized.output);
const detailName =
normalized.toolKind === "shell"
? "shell"
: normalized.toolKind === "read"
? "read_file"
: normalized.toolKind === "write"
? "write_file"
: normalized.toolKind === "edit"
? "apply_patch"
: normalized.toolKind === "search"
? "search"
: normalized.toolKind === "speak"
? "speak"
: normalized.name;
const detail = deriveClaudeToolDetail(detailName, normalized.input, normalized.output);
if (normalized.status === "failed") {
return {
type: "tool_call",
@@ -174,6 +174,7 @@ describe("createWorktree", () => {
'echo "root=$PASEO_ROOT_PATH" > setup.log',
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
'echo "branch=$PASEO_BRANCH_NAME" >> setup.log',
'echo "port=$PASEO_WORKTREE_PORT" >> setup.log',
],
},
};
@@ -195,6 +196,13 @@ describe("createWorktree", () => {
expect(setupLog).toContain(`root=${repoDir}`);
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
expect(setupLog).toContain("branch=setup-test");
const portLine = setupLog
.split("\n")
.find((line) => line.startsWith("port="));
expect(portLine).toBeDefined();
const portValue = Number(portLine?.slice("port=".length));
expect(Number.isInteger(portValue)).toBe(true);
expect(portValue).toBeGreaterThan(0);
});
it("does not run setup commands when runSetup=false", async () => {
+24
View File
@@ -2,6 +2,7 @@ import { exec } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createNameId } from "mnemonic-id";
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
@@ -136,6 +137,27 @@ async function execSetupCommand(
}
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire available port")));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(worktreePath);
@@ -178,6 +200,7 @@ export async function runWorktreeSetupCommands(options: {
const repoRootPath =
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const worktreePort = await getAvailablePort();
const setupEnv = {
...process.env,
@@ -186,6 +209,7 @@ export async function runWorktreeSetupCommands(options: {
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: options.branchName,
PASEO_WORKTREE_PORT: String(worktreePort),
};
const results: WorktreeSetupCommandResult[] = [];