feat: improve Claude agent model normalization and tool mapping
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+209
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user