refactor: migrate from clientSessionKey to clientId identity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
+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