refactor: migrate from clientSessionKey to clientId identity
This commit is contained in:
+12
-12
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user