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
+8
View File
@@ -19,6 +19,14 @@
"env": {
"APP_VARIANT": "production"
}
},
"production-apk": {
"extends": "production",
"distribution": "internal",
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
}
},
"submit": {
+34 -17
View File
@@ -429,8 +429,9 @@ export default function SettingsScreen() {
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
const editingDaemonLive = editingDaemon
? daemons.find((daemon) => daemon.serverId === editingDaemon.serverId) ?? null
const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
: null;
const pendingNameHostname = useSessionStore(
useCallback(
@@ -448,6 +449,13 @@ export default function SettingsScreen() {
};
}, []);
// Keep the edit modal bound to live registry state.
useEffect(() => {
if (!editingServerId) return;
if (editingDaemonLive) return;
setEditingDaemon(null);
}, [editingDaemonLive, editingServerId]);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
@@ -516,7 +524,7 @@ export default function SettingsScreen() {
]);
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingDaemon) return;
if (!editingServerId) return;
if (isSavingEdit) return;
const nextLabel = nextLabelRaw.trim();
@@ -527,7 +535,7 @@ export default function SettingsScreen() {
try {
setIsSavingEdit(true);
await updateHost(editingDaemon.serverId, { label: nextLabel });
await updateHost(editingServerId, { label: nextLabel });
handleCloseEditDaemon();
} catch (error) {
console.error("[Settings] Failed to rename host", error);
@@ -535,7 +543,7 @@ export default function SettingsScreen() {
} finally {
setIsSavingEdit(false);
}
}, [editingDaemon, handleCloseEditDaemon, isSavingEdit, updateHost]);
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
const handleRemoveConnection = useCallback(
async (serverId: string, connectionId: string) => {
@@ -550,13 +558,13 @@ export default function SettingsScreen() {
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingDaemon) return;
const serverId = editingDaemon.serverId;
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingDaemon]);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
@@ -763,11 +771,11 @@ export default function SettingsScreen() {
) : null}
<HostDetailModal
visible={Boolean(editingDaemon)}
host={editingDaemonLive ?? editingDaemon}
connectionStatus={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.status ?? "idle") : "idle"}
activeConnection={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.activeConnection ?? null) : null}
lastError={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.lastError ?? null) : null}
visible={Boolean(editingDaemonLive)}
host={editingDaemonLive}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
isSaving={isSavingEdit}
onClose={handleCloseEditDaemon}
onSave={(label) => void handleSaveEditDaemon(label)}
@@ -873,6 +881,7 @@ function HostDetailModal({
}: HostDetailModalProps) {
const { theme } = useUnistyles();
const [draftLabel, setDraftLabel] = useState("");
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
const activeServerIdRef = useRef<string | null>(null);
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
@@ -1018,22 +1027,30 @@ function HostDetailModal({
})();
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const handleDraftLabelChange = useCallback((nextValue: string) => {
setDraftLabel(nextValue);
setIsDraftLabelDirty(true);
}, []);
useEffect(() => {
if (!visible || !host) return;
if (activeServerIdRef.current !== host.serverId) {
const hostChanged = activeServerIdRef.current !== host.serverId;
if (hostChanged) {
setDraftLabel(host.label ?? "");
setIsDraftLabelDirty(false);
activeServerIdRef.current = host.serverId;
return;
}
if (!draftLabel.trim()) {
if (!isDraftLabelDirty) {
setDraftLabel(host.label ?? "");
}
}, [visible, host, draftLabel]);
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
useEffect(() => {
if (!visible) {
activeServerIdRef.current = null;
setIsRestarting(false);
setIsDraftLabelDirty(false);
}
}, [visible]);
@@ -1072,7 +1089,7 @@ function HostDetailModal({
<AdaptiveTextInput
style={styles.input}
value={draftLabel}
onChangeText={setDraftLabel}
onChangeText={handleDraftLabelChange}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
@@ -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[] = [];
@@ -138,6 +138,9 @@ function Worktrees() {
<li>
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
</li>
<li>
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for setup scripts
</li>
</ul>
<p className="text-white/60 leading-relaxed">
Use <code className="font-mono">$PASEO_ROOT_PATH</code> to copy files that shouldn't