refactor: migrate from clientSessionKey to clientId identity

This commit is contained in:
Mohamed Boudra
2026-02-21 15:33:29 +07:00
parent e906bffbb9
commit c3c8103563
56 changed files with 3289 additions and 941 deletions
@@ -227,6 +227,10 @@ export function AgentInputArea({
useEffect(() => {
const previousUpdatedAt = latestAgentUpdatedAtRef.current
if (agentUpdatedAtMs < previousUpdatedAt) {
if (isProcessing && !isAgentRunning) {
prevIsAgentRunningRef.current = false
setIsProcessing(false)
}
return
}
@@ -48,7 +48,8 @@ export function ToolCallDetailsContent({
(detail?.type === "shell" ||
detail?.type === "edit" ||
detail?.type === "write" ||
detail?.type === "read");
detail?.type === "read" ||
detail?.type === "sub_agent");
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
if (detail?.type === "shell") {
@@ -126,6 +127,45 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "sub_agent") {
const activityLog = detail.log.replace(/^\n+/, "");
const hasLog = activityLog.length > 0;
const fallbackHeader =
detail.subAgentType && detail.description
? `${detail.subAgentType}: ${detail.description}`
: detail.subAgentType ?? detail.description ?? "Sub-agent activity";
sections.push(
<View
key="sub-agent"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog ? activityLog : fallbackHeader}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View
+44 -8
View File
@@ -445,8 +445,6 @@ function SessionProviderInternal({
}),
};
console.log("[Session] Agent update:", agent.id, agent.status);
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
@@ -469,15 +467,53 @@ function SessionProviderInternal({
setAgentLastActivity(agent.id, agent.lastActivityAt);
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
for (const [key, pending] of Array.from(next.entries())) {
const existingKeysForAgent: string[] = [];
for (const [key, pending] of prev.entries()) {
if (pending.agentId === agent.id) {
next.delete(key);
existingKeysForAgent.push(key);
}
}
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
next.set(key, { key, agentId: agent.id, request });
const nextEntries = agent.pendingPermissions.map((request) => ({
key: derivePendingPermissionKey(agent.id, request),
agentId: agent.id,
request,
}));
let changed = existingKeysForAgent.length !== nextEntries.length;
if (!changed) {
const existingKeySet = new Set(existingKeysForAgent);
for (const entry of nextEntries) {
const existing = prev.get(entry.key);
if (!existingKeySet.has(entry.key) || !existing) {
changed = true;
break;
}
const currentRequest = existing.request;
if (
currentRequest.id !== entry.request.id ||
currentRequest.kind !== entry.request.kind ||
currentRequest.name !== entry.request.name ||
currentRequest.title !== entry.request.title ||
currentRequest.description !== entry.request.description
) {
changed = true;
break;
}
}
}
if (!changed) {
return prev;
}
const next = new Map(prev);
for (const key of existingKeysForAgent) {
next.delete(key);
}
for (const entry of nextEntries) {
next.set(entry.key, entry);
}
return next;
});
@@ -47,10 +47,9 @@ export function useAllAgentsList(options?: {
: null;
}, [options?.serverId]);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents ?? null : null
);
const liveAgents = session?.agents ?? null;
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const refreshAll = useCallback(() => {
@@ -168,10 +168,9 @@ export function useSidebarAgentsList(options?: {
[options?.selectedProjectFilterKeys]
);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents ?? null : null
);
const liveAgents = session?.agents ?? null;
const { snapshot } = useHostRuntimeSession(serverId ?? "");
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
+13 -13
View File
@@ -134,7 +134,7 @@ function makeDeps(
}
return value;
},
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
};
}
@@ -169,7 +169,7 @@ describe("HostRuntimeController", () => {
measureLatency: async () => {
throw new Error("probe unavailable");
},
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
};
const controller = new HostRuntimeController({
host,
@@ -188,7 +188,7 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
});
it("passes resolved client session key into created active clients", async () => {
it("passes resolved client id into created active clients", async () => {
const host = makeHost({
connections: [
{
@@ -198,23 +198,23 @@ describe("HostRuntimeController", () => {
},
],
});
const seenSessionKeys: string[] = [];
const seenClientIds: string[] = [];
const fakeClient = new FakeDaemonClient();
const controller = new HostRuntimeController({
host,
deps: {
createClient: ({ clientSessionKey }) => {
seenSessionKeys.push(clientSessionKey);
createClient: ({ clientId }) => {
seenClientIds.push(clientId);
return fakeClient as unknown as DaemonClient;
},
measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_runtime_stable",
getClientId: async () => "cid_runtime_stable",
},
});
await controller.start({ autoProbe: false });
expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]);
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online");
});
@@ -535,7 +535,7 @@ describe("HostRuntimeController", () => {
return client as unknown as DaemonClient;
},
measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
};
const controller = new HostRuntimeController({
host,
@@ -621,7 +621,7 @@ describe("HostRuntimeController", () => {
}
throw new Error("unexpected probe call");
},
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
},
});
@@ -685,7 +685,7 @@ describe("HostRuntimeController", () => {
}
return 10;
},
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
},
});
@@ -730,7 +730,7 @@ describe("HostRuntimeStore", () => {
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
},
});
@@ -772,7 +772,7 @@ describe("HostRuntimeStore", () => {
measureLatency: async () => {
throw new Error("probe unavailable");
},
getClientSessionKey: async () => "clsk_test_runtime",
getClientId: async () => "cid_test_runtime",
},
});
+20 -20
View File
@@ -10,7 +10,7 @@ import {
buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints";
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { getOrCreateClientSessionKey } from "@/utils/client-session-key";
import { getOrCreateClientId } from "@/utils/client-id";
import {
selectBestConnection,
type ConnectionCandidate,
@@ -118,14 +118,14 @@ export type HostRuntimeControllerDeps = {
createClient: (input: {
host: HostProfile;
connection: HostConnection;
clientSessionKey: string;
clientId: string;
runtimeGeneration: number;
}) => DaemonClient;
measureLatency: (input: {
host: HostProfile;
connection: HostConnection;
}) => Promise<number>;
getClientSessionKey: () => Promise<string>;
getClientId: () => Promise<string>;
};
export type HostRuntimeStartOptions = {
@@ -374,11 +374,12 @@ function selectInitialConnectionId(host: HostProfile): string | null {
function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientSessionKey, runtimeGeneration }) => {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
suppressSendErrors: true,
clientSessionKey,
clientId,
clientType: "mobile" as const,
runtimeGeneration,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
@@ -387,7 +388,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "direct") {
return new DaemonClient({
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
url: buildDaemonWebSocketUrl(connection.endpoint),
});
}
return new DaemonClient({
@@ -395,7 +396,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId: host.serverId,
clientSessionKey,
}),
e2ee: {
enabled: true,
@@ -405,7 +405,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
},
measureLatency: ({ host, connection }) =>
measureConnectionLatency(connection, { serverId: host.serverId }),
getClientSessionKey: () => getOrCreateClientSessionKey(),
getClientId: () => getOrCreateClientId(),
};
}
@@ -421,8 +421,8 @@ export class HostRuntimeController {
private started = false;
private switchCandidateConnectionId: string | null = null;
private switchCandidateHitCount = 0;
private clientSessionKeyPromise: Promise<string> | null = null;
private clientSessionKeyHash: string | null = null;
private clientIdPromise: Promise<string> | null = null;
private clientIdHash: string | null = null;
private switchRequestVersion = 0;
private probeRequestVersion = 0;
@@ -740,7 +740,7 @@ export class HostRuntimeController {
: toReasonCode(reason);
console.info("[HostRuntimeTransition]", {
serverId: this.host.serverId,
clientSessionKeyHash: this.clientSessionKeyHash,
clientIdHash: this.clientIdHash,
from: input.from,
to: input.to,
event: event.type,
@@ -782,9 +782,9 @@ export class HostRuntimeController {
}
const requestVersion = ++this.switchRequestVersion;
let clientSessionKey: string;
let clientId: string;
try {
clientSessionKey = await this.resolveClientSessionKey();
clientId = await this.resolveClientId();
} catch (error) {
if (!this.isCurrentSwitchRequest(requestVersion)) {
return;
@@ -792,7 +792,7 @@ export class HostRuntimeController {
const message = toErrorMessage(error);
this.applyConnectionEvent({
type: "connect_failed",
message: `Failed to resolve client session key: ${message}`,
message: `Failed to resolve client id: ${message}`,
});
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
@@ -827,7 +827,7 @@ export class HostRuntimeController {
const client = this.deps.createClient({
host: this.host,
connection,
clientSessionKey,
clientId,
runtimeGeneration: nextGeneration,
});
if (!this.isCurrentSwitchRequest(requestVersion)) {
@@ -910,14 +910,14 @@ export class HostRuntimeController {
}
}
private resolveClientSessionKey(): Promise<string> {
if (!this.clientSessionKeyPromise) {
this.clientSessionKeyPromise = this.deps.getClientSessionKey().then((value) => {
this.clientSessionKeyHash = hashForLog(value);
private resolveClientId(): Promise<string> {
if (!this.clientIdPromise) {
this.clientIdPromise = this.deps.getClientId().then((value) => {
this.clientIdHash = hashForLog(value);
return value;
});
}
return this.clientSessionKeyPromise;
return this.clientIdPromise;
}
}
+79
View File
@@ -159,6 +159,85 @@ describe("stream reducer canonical tool calls", () => {
});
});
it("keeps sub_agent detail through lifecycle updates for the same callId", () => {
const callId = "task-sub-agent-1";
const updates = [
{
event: canonicalToolTimeline({
provider: "claude",
callId,
name: "Task",
status: "running",
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md\n[Bash] ls",
actions: [
{
index: 1,
toolName: "Read",
summary: "README.md",
},
{
index: 2,
toolName: "Bash",
summary: "ls",
},
],
},
}),
timestamp: new Date("2025-01-01T10:12:00Z"),
},
{
event: canonicalToolTimeline({
provider: "claude",
callId,
name: "Task",
status: "completed",
input: null,
output: { ok: true },
}),
timestamp: new Date("2025-01-01T10:12:01Z"),
},
];
const state = hydrateStreamState(updates);
const tools = state.filter(isAgentToolCallItem);
assert.strictEqual(tools.length, 1);
assert.strictEqual(tools[0].payload.data.status, "completed");
assert.deepStrictEqual(tools[0].payload.data.detail, {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md\n[Bash] ls",
actions: [
{
index: 1,
toolName: "Read",
summary: "README.md",
},
{
index: 2,
toolName: "Bash",
summary: "ls",
},
],
});
const display = buildToolCallDisplayModel({
name: tools[0].payload.data.name,
status: tools[0].payload.data.status,
error: tools[0].payload.data.error,
detail: tools[0].payload.data.detail,
});
assert.deepStrictEqual(display, {
displayName: "Explore",
summary: "Inspect repository structure",
});
});
it("exposes shell summary from running input before completion", () => {
const callId = "running-summary-shell";
const state = hydrateStreamState([
@@ -9,35 +9,35 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("client-session-key", () => {
describe("client-id", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("returns stored client session key when present", async () => {
asyncStorageMock.getItem.mockResolvedValue("clsk_existing");
const mod = await import("./client-session-key");
it("returns stored client id when present", async () => {
asyncStorageMock.getItem.mockResolvedValue("cid_existing");
const mod = await import("./client-id");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_existing");
const key = await mod.getOrCreateClientId();
expect(key).toBe("cid_existing");
expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1);
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("creates and persists a client session key when missing", async () => {
it("creates and persists a client id when missing", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
const mod = await import("./client-id");
const key = await mod.getOrCreateClientId();
expect(key).toBe("clsk_123456781234123412341234567890ab");
expect(key).toBe("cid_123456781234123412341234567890ab");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:client-session-key-v1",
"clsk_123456781234123412341234567890ab"
"@paseo:client-id-v1",
"cid_123456781234123412341234567890ab"
);
});
});
+54
View File
@@ -0,0 +1,54 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1";
let cachedClientId: string | null = null;
let inFlightClientId: Promise<string> | null = null;
function generateClientId(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `cid_${randomUuid}`;
}
function normalizeStoredClientId(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function getOrCreateClientId(): Promise<string> {
if (cachedClientId) {
return cachedClientId;
}
if (inFlightClientId) {
return inFlightClientId;
}
inFlightClientId = (async () => {
const storedValue = await AsyncStorage.getItem(CLIENT_ID_STORAGE_KEY);
const existing = normalizeStoredClientId(storedValue);
if (existing) {
cachedClientId = existing;
return existing;
}
const nextValue = generateClientId();
await AsyncStorage.setItem(CLIENT_ID_STORAGE_KEY, nextValue);
cachedClientId = nextValue;
return nextValue;
})();
try {
return await inFlightClientId;
} finally {
inFlightClientId = null;
}
}
@@ -1,54 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const CLIENT_SESSION_KEY_STORAGE_KEY = "@paseo:client-session-key-v1";
let cachedClientSessionKey: string | null = null;
let inFlightClientSessionKey: Promise<string> | null = null;
function generateClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_${randomUuid}`;
}
function normalizeStoredClientSessionKey(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function getOrCreateClientSessionKey(): Promise<string> {
if (cachedClientSessionKey) {
return cachedClientSessionKey;
}
if (inFlightClientSessionKey) {
return inFlightClientSessionKey;
}
inFlightClientSessionKey = (async () => {
const storedValue = await AsyncStorage.getItem(CLIENT_SESSION_KEY_STORAGE_KEY);
const existing = normalizeStoredClientSessionKey(storedValue);
if (existing) {
cachedClientSessionKey = existing;
return existing;
}
const nextValue = generateClientSessionKey();
await AsyncStorage.setItem(CLIENT_SESSION_KEY_STORAGE_KEY, nextValue);
cachedClientSessionKey = nextValue;
return nextValue;
})();
try {
return await inFlightClientSessionKey;
} finally {
inFlightClientSessionKey = null;
}
}
@@ -33,7 +33,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
clientSessionKey?: string;
}): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
@@ -1,21 +1,19 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = [];
const createdConfigs: Array<{ clientId?: string; url?: string }> = [];
class MockDaemonClient {
private statusHandlers = new Set<
(
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
>();
public lastError: string | null = null;
private lastWelcome = {
type: "welcome" as const,
serverId: "srv_probe_test",
hostname: "probe-host" as string | null,
version: "0.0.0",
resumed: false,
};
constructor(config: { clientSessionKey?: string; url?: string }) {
constructor(config: { clientId?: string; url?: string }) {
createdConfigs.push(config);
}
@@ -23,35 +21,16 @@ const daemonClientMock = vi.hoisted(() => {
return () => undefined;
}
on(
event: "status",
handler: (
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
): () => void {
if (event === "status") {
this.statusHandlers.add(handler);
}
return () => {
this.statusHandlers.delete(handler);
};
on(): () => void {
return () => undefined;
}
async connect(): Promise<void> {
const message = {
type: "status" as const,
payload: {
status: "server_info",
serverId: "srv_probe_test",
hostname: "probe-host",
},
};
for (const handler of this.statusHandlers) {
handler(message);
}
return;
}
getLastWelcomeMessage() {
return this.lastWelcome;
}
async ping(): Promise<{ rttMs: number }> {
@@ -78,7 +57,7 @@ describe("test-daemon-connection probe client identity", () => {
daemonClientMock.createdConfigs.length = 0;
});
it("uses isolated probe clientSessionKey values for direct latency probes", async () => {
it("uses isolated probe clientId values for direct latency probes", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
@@ -93,8 +72,8 @@ describe("test-daemon-connection probe client identity", () => {
});
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(second?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey);
expect(first?.clientId).toMatch(/^cid_probe_/);
expect(second?.clientId).toMatch(/^cid_probe_/);
expect(first?.clientId).not.toBe(second?.clientId);
});
});
@@ -1,10 +1,9 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientSessionKey(): string {
function createProbeClientId(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
@@ -12,7 +11,7 @@ function createProbeClientSessionKey(): string {
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_probe_${randomUuid}`;
return `cid_probe_${randomUuid}`;
}
function normalizeNonEmptyString(value: unknown): string | null {
@@ -55,10 +54,11 @@ async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
const clientSessionKey = createProbeClientSessionKey();
const clientId = createProbeClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
clientSessionKey,
clientId,
clientType: "mobile" as const,
suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
};
@@ -66,7 +66,7 @@ async function buildClientConfig(
if (connection.type === "direct") {
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
url: buildDaemonWebSocketUrl(connection.endpoint),
};
}
@@ -79,7 +79,6 @@ async function buildClientConfig(
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId,
clientSessionKey,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
};
@@ -92,35 +91,9 @@ function connectAndProbe(
const client = new DaemonClient(config);
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => {
let cleanedUp = false;
let unsubscribe: (() => void) | null = null;
let unsubscribeStatus: (() => void) | null = null;
let serverId: string | null = null;
let hostname: string | null = null;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
clearTimeout(timer);
unsubscribe?.();
unsubscribeStatus?.();
};
const maybeFinishOk = () => {
if (!serverId) return;
cleanup();
resolve({ client, serverId, hostname });
};
const finishErr = (error: Error) => {
if (cleanedUp) return;
cleanup();
client.close().catch(() => undefined);
reject(error);
};
const timer = setTimeout(() => {
finishErr(
void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Connection timed out", {
reason: "Connection timed out",
lastError: client.lastError ?? null,
@@ -128,25 +101,32 @@ function connectAndProbe(
);
}, timeoutMs);
unsubscribe = client.subscribeConnectionStatus((state) => {
if (state.status === "disconnected") {
const reason = normalizeNonEmptyString(state.reason);
const lastError = normalizeNonEmptyString(client.lastError);
const message = pickBestReason(reason, lastError);
finishErr(new DaemonConnectionTestError(message, { reason, lastError }));
void client.connect().then(() => {
clearTimeout(timer);
const welcome = client.getLastWelcomeMessage();
if (!welcome) {
void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Missing welcome message", {
reason: "Missing welcome message",
lastError: client.lastError ?? null,
})
);
return;
}
resolve({
client,
serverId: welcome.serverId,
hostname: welcome.hostname,
});
}).catch((error) => {
clearTimeout(timer);
const reason = normalizeNonEmptyString(error instanceof Error ? error.message : String(error));
const lastError = normalizeNonEmptyString(client.lastError);
const message = pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError }));
});
unsubscribeStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const payload = parseServerInfoStatusPayload(message.payload);
if (!payload) return;
serverId = payload.serverId;
hostname = payload.hostname;
maybeFinishOk();
});
void client.connect().catch(() => undefined);
});
}
@@ -38,24 +38,29 @@ describe("tool-call-display", () => {
});
});
it("uses metadata summary for task tool calls", () => {
it("uses sub-agent detail for task label and description", () => {
const display = buildToolCallDisplayModel({
name: "task",
status: "running",
error: null,
detail: {
type: "unknown",
input: null,
output: null,
},
metadata: {
subAgentActivity: "Running tests",
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository structure",
log: "[Read] README.md",
actions: [
{
index: 1,
toolName: "Read",
summary: "README.md",
},
],
},
});
expect(display).toEqual({
displayName: "Task",
summary: "Running tests",
displayName: "Explore",
summary: "Inspect repository structure",
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
const iconMocks = vi.hoisted(() => ({
Bot: () => null,
Brain: () => null,
Eye: () => null,
MicVocal: () => null,
Pencil: () => null,
Search: () => null,
SquareTerminal: () => null,
Wrench: () => null,
}));
vi.mock("lucide-react-native", () => iconMocks);
import { resolveToolCallIcon } from "./tool-call-icon";
describe("tool-call-icon", () => {
it("uses robot icon for task sub-agent details", () => {
const icon = resolveToolCallIcon("Task", {
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect repository",
log: "[Read] README.md",
actions: [{ index: 1, toolName: "Read", summary: "README.md" }],
});
expect(icon).toBe(iconMocks.Bot);
});
it("uses robot icon for task calls without canonical detail", () => {
const icon = resolveToolCallIcon("Task", {
type: "unknown",
input: null,
output: null,
});
expect(icon).toBe(iconMocks.Bot);
expect(resolveToolCallIcon("Task")).toBe(iconMocks.Bot);
});
it("keeps thinking icon override for unknown detail", () => {
const icon = resolveToolCallIcon("thinking", {
type: "unknown",
input: null,
output: null,
});
expect(icon).toBe(iconMocks.Brain);
});
});
+4 -4
View File
@@ -20,6 +20,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
write: Pencil,
search: Search,
worktree_setup: SquareTerminal,
sub_agent: Bot,
unknown: Wrench,
};
@@ -33,13 +34,12 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
if (lowerName === "speak") {
return MicVocal;
}
if (lowerName === "task") {
return Bot;
}
if (detail) {
return TOOL_DETAIL_ICONS[detail.type];
}
if (lowerName === "task") {
return Bot;
}
return Wrench;
}