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
@@ -0,0 +1,81 @@
# Claude Provider Invariants and Regression Test Plan
## Goal
Lock down behavior for Claude provider stream handling without changing provider interface contracts.
## Non-Negotiable Contract Invariants
- `I1` Provider interface remains unchanged.
- `stream(prompt) -> AsyncGenerator<AgentStreamEvent>` stays the only provider surface for run streaming.
- Reference: `packages/server/src/server/agent/agent-sdk-types.ts:337`
- `I2` One prompt submission produces exactly one terminal turn event.
- Terminal set: `turn_completed | turn_failed | turn_canceled`.
- No ambiguous completion path.
- `I3` Wait semantics remain status-based, not message-correlation-based.
- `waitForFinish` resolves on lifecycle outcomes (`idle|error|permission|timeout`).
- References:
- `packages/server/src/server/session.ts:5366`
- `packages/server/src/shared/messages.ts:1523`
- `I4` App chat/input semantics remain status-transition-based.
- Input processing and queue flush continue to rely on `running` transitions and `updatedAt` ordering.
- References:
- `packages/app/src/components/agent-input-area.tsx:222`
- `packages/app/src/components/agent-input-area.tsx:300`
- `packages/app/src/contexts/session-context.tsx:485`
- `I5` New prompt must never be satisfied by stale pre-prompt assistant/result events.
- Old queued events (including system-triggered activity) must not be misattributed as response to latest prompt.
## Existing Coverage (Keep)
- Wait semantics after immediate/rapid sends:
- `packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts:33`
- Send-while-running recovery under real providers:
- `packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts:18`
- `packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts:18`
- Claude stale old-turn protection (interrupt-failure path):
- `packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts:217`
- App stream head/tail flush on terminal events:
- `packages/app/src/types/stream-event.test.ts:115`
## Missing Coverage (Add)
### M1: Task Notification Interleaving Regression (Claude Unit)
- Add mocked SDK sequence where a queued system/task-notification-related burst causes old assistant/result events to appear before new prompt output.
- Assert latest `session.stream(newPrompt)` does not emit stale assistant text as its reply.
- Expected: `I5` enforced.
### M2: Stale Result Preemption Regression (Claude Unit)
- Add mocked SDK sequence with old pending `result` available right after pushing a new prompt.
- Assert provider ignores/contains stale terminal for previous activity and does not complete new prompt prematurely.
- Expected: `I2` + `I5` enforced.
### M3: Wait Final Snapshot Coherence (Daemon E2E/Integration)
- Add/strengthen test to ensure `waitForFinish` never returns `status: idle` while `final.status === running`.
- Remove need for retry loop workaround in stress scenarios.
- Current stress workaround location:
- `packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts:387`
- Expected: `I3` hard guarantee.
## Implementation Notes (Provider-Internal Only)
- Turn events are valid internal lifecycle markers.
- Do not introduce user-message to assistant-message correlation as a new cross-layer contract.
- Do not modify app/CLI/public daemon wait interfaces.
- Keep all changes scoped to Claude provider internals and regression tests.
## Definition of Done
- Invariants `I1-I5` are unchanged and explicitly covered.
- New tests for `M1-M3` are added and passing.
- Existing tests listed above remain green.
- `npm run typecheck` passes.
@@ -227,6 +227,10 @@ export function AgentInputArea({
useEffect(() => { useEffect(() => {
const previousUpdatedAt = latestAgentUpdatedAtRef.current const previousUpdatedAt = latestAgentUpdatedAtRef.current
if (agentUpdatedAtMs < previousUpdatedAt) { if (agentUpdatedAtMs < previousUpdatedAt) {
if (isProcessing && !isAgentRunning) {
prevIsAgentRunningRef.current = false
setIsProcessing(false)
}
return return
} }
@@ -48,7 +48,8 @@ export function ToolCallDetailsContent({
(detail?.type === "shell" || (detail?.type === "shell" ||
detail?.type === "edit" || detail?.type === "edit" ||
detail?.type === "write" || detail?.type === "write" ||
detail?.type === "read"); detail?.type === "read" ||
detail?.type === "sub_agent");
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
if (detail?.type === "shell") { if (detail?.type === "shell") {
@@ -126,6 +127,45 @@ export function ToolCallDetailsContent({
</View> </View>
</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") { } else if (detail?.type === "edit") {
sections.push( sections.push(
<View <View
+44 -8
View File
@@ -445,8 +445,6 @@ function SessionProviderInternal({
}), }),
}; };
console.log("[Session] Agent update:", agent.id, agent.status);
setAgents(serverId, (prev) => { setAgents(serverId, (prev) => {
const current = prev.get(agent.id); const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) { if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
@@ -469,15 +467,53 @@ function SessionProviderInternal({
setAgentLastActivity(agent.id, agent.lastActivityAt); setAgentLastActivity(agent.id, agent.lastActivityAt);
setPendingPermissions(serverId, (prev) => { setPendingPermissions(serverId, (prev) => {
const next = new Map(prev); const existingKeysForAgent: string[] = [];
for (const [key, pending] of Array.from(next.entries())) { for (const [key, pending] of prev.entries()) {
if (pending.agentId === agent.id) { if (pending.agentId === agent.id) {
next.delete(key); existingKeysForAgent.push(key);
} }
} }
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request); const nextEntries = agent.pendingPermissions.map((request) => ({
next.set(key, { key, agentId: agent.id, 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; return next;
}); });
@@ -47,10 +47,9 @@ export function useAllAgentsList(options?: {
: null; : null;
}, [options?.serverId]); }, [options?.serverId]);
const session = useSessionStore((state) => const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined serverId ? state.sessions[serverId]?.agents ?? null : null
); );
const liveAgents = session?.agents ?? null;
const { snapshot } = useHostRuntimeSession(serverId ?? ""); const { snapshot } = useHostRuntimeSession(serverId ?? "");
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
@@ -168,10 +168,9 @@ export function useSidebarAgentsList(options?: {
[options?.selectedProjectFilterKeys] [options?.selectedProjectFilterKeys]
); );
const session = useSessionStore((state) => const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined serverId ? state.sessions[serverId]?.agents ?? null : null
); );
const liveAgents = session?.agents ?? null;
const { snapshot } = useHostRuntimeSession(serverId ?? ""); const { snapshot } = useHostRuntimeSession(serverId ?? "");
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => { const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
+13 -13
View File
@@ -134,7 +134,7 @@ function makeDeps(
} }
return value; return value;
}, },
getClientSessionKey: async () => "clsk_test_runtime", getClientId: async () => "cid_test_runtime",
}; };
} }
@@ -169,7 +169,7 @@ describe("HostRuntimeController", () => {
measureLatency: async () => { measureLatency: async () => {
throw new Error("probe unavailable"); throw new Error("probe unavailable");
}, },
getClientSessionKey: async () => "clsk_test_runtime", getClientId: async () => "cid_test_runtime",
}; };
const controller = new HostRuntimeController({ const controller = new HostRuntimeController({
host, host,
@@ -188,7 +188,7 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading"); 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({ const host = makeHost({
connections: [ connections: [
{ {
@@ -198,23 +198,23 @@ describe("HostRuntimeController", () => {
}, },
], ],
}); });
const seenSessionKeys: string[] = []; const seenClientIds: string[] = [];
const fakeClient = new FakeDaemonClient(); const fakeClient = new FakeDaemonClient();
const controller = new HostRuntimeController({ const controller = new HostRuntimeController({
host, host,
deps: { deps: {
createClient: ({ clientSessionKey }) => { createClient: ({ clientId }) => {
seenSessionKeys.push(clientSessionKey); seenClientIds.push(clientId);
return fakeClient as unknown as DaemonClient; return fakeClient as unknown as DaemonClient;
}, },
measureLatency: async () => 10, measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_runtime_stable", getClientId: async () => "cid_runtime_stable",
}, },
}); });
await controller.start({ autoProbe: false }); await controller.start({ autoProbe: false });
expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]); expect(seenClientIds).toEqual(["cid_runtime_stable"]);
expect(controller.getSnapshot().connectionStatus).toBe("online"); expect(controller.getSnapshot().connectionStatus).toBe("online");
}); });
@@ -535,7 +535,7 @@ describe("HostRuntimeController", () => {
return client as unknown as DaemonClient; return client as unknown as DaemonClient;
}, },
measureLatency: async () => 10, measureLatency: async () => 10,
getClientSessionKey: async () => "clsk_test_runtime", getClientId: async () => "cid_test_runtime",
}; };
const controller = new HostRuntimeController({ const controller = new HostRuntimeController({
host, host,
@@ -621,7 +621,7 @@ describe("HostRuntimeController", () => {
} }
throw new Error("unexpected probe call"); throw new Error("unexpected probe call");
}, },
getClientSessionKey: async () => "clsk_test_runtime", getClientId: async () => "cid_test_runtime",
}, },
}); });
@@ -685,7 +685,7 @@ describe("HostRuntimeController", () => {
} }
return 10; return 10;
}, },
getClientSessionKey: async () => "clsk_test_runtime", getClientId: async () => "cid_test_runtime",
}, },
}); });
@@ -730,7 +730,7 @@ describe("HostRuntimeStore", () => {
deps: { deps: {
createClient: () => fakeClient as unknown as DaemonClient, createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5, measureLatency: async () => 5,
getClientSessionKey: async () => "clsk_test_runtime", getClientId: async () => "cid_test_runtime",
}, },
}); });
@@ -772,7 +772,7 @@ describe("HostRuntimeStore", () => {
measureLatency: async () => { measureLatency: async () => {
throw new Error("probe unavailable"); 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, buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints"; } from "@/utils/daemon-endpoints";
import { measureConnectionLatency } from "@/utils/test-daemon-connection"; import { measureConnectionLatency } from "@/utils/test-daemon-connection";
import { getOrCreateClientSessionKey } from "@/utils/client-session-key"; import { getOrCreateClientId } from "@/utils/client-id";
import { import {
selectBestConnection, selectBestConnection,
type ConnectionCandidate, type ConnectionCandidate,
@@ -118,14 +118,14 @@ export type HostRuntimeControllerDeps = {
createClient: (input: { createClient: (input: {
host: HostProfile; host: HostProfile;
connection: HostConnection; connection: HostConnection;
clientSessionKey: string; clientId: string;
runtimeGeneration: number; runtimeGeneration: number;
}) => DaemonClient; }) => DaemonClient;
measureLatency: (input: { measureLatency: (input: {
host: HostProfile; host: HostProfile;
connection: HostConnection; connection: HostConnection;
}) => Promise<number>; }) => Promise<number>;
getClientSessionKey: () => Promise<string>; getClientId: () => Promise<string>;
}; };
export type HostRuntimeStartOptions = { export type HostRuntimeStartOptions = {
@@ -374,11 +374,12 @@ function selectInitialConnectionId(host: HostProfile): string | null {
function createDefaultDeps(): HostRuntimeControllerDeps { function createDefaultDeps(): HostRuntimeControllerDeps {
return { return {
createClient: ({ host, connection, clientSessionKey, runtimeGeneration }) => { createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = { const base = {
suppressSendErrors: true, suppressSendErrors: true,
clientSessionKey, clientId,
clientType: "mobile" as const,
runtimeGeneration, runtimeGeneration,
...(tauriTransportFactory ...(tauriTransportFactory
? { transportFactory: tauriTransportFactory } ? { transportFactory: tauriTransportFactory }
@@ -387,7 +388,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "direct") { if (connection.type === "direct") {
return new DaemonClient({ return new DaemonClient({
...base, ...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }), url: buildDaemonWebSocketUrl(connection.endpoint),
}); });
} }
return new DaemonClient({ return new DaemonClient({
@@ -395,7 +396,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint, endpoint: connection.relayEndpoint,
serverId: host.serverId, serverId: host.serverId,
clientSessionKey,
}), }),
e2ee: { e2ee: {
enabled: true, enabled: true,
@@ -405,7 +405,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
}, },
measureLatency: ({ host, connection }) => measureLatency: ({ host, connection }) =>
measureConnectionLatency(connection, { serverId: host.serverId }), measureConnectionLatency(connection, { serverId: host.serverId }),
getClientSessionKey: () => getOrCreateClientSessionKey(), getClientId: () => getOrCreateClientId(),
}; };
} }
@@ -421,8 +421,8 @@ export class HostRuntimeController {
private started = false; private started = false;
private switchCandidateConnectionId: string | null = null; private switchCandidateConnectionId: string | null = null;
private switchCandidateHitCount = 0; private switchCandidateHitCount = 0;
private clientSessionKeyPromise: Promise<string> | null = null; private clientIdPromise: Promise<string> | null = null;
private clientSessionKeyHash: string | null = null; private clientIdHash: string | null = null;
private switchRequestVersion = 0; private switchRequestVersion = 0;
private probeRequestVersion = 0; private probeRequestVersion = 0;
@@ -740,7 +740,7 @@ export class HostRuntimeController {
: toReasonCode(reason); : toReasonCode(reason);
console.info("[HostRuntimeTransition]", { console.info("[HostRuntimeTransition]", {
serverId: this.host.serverId, serverId: this.host.serverId,
clientSessionKeyHash: this.clientSessionKeyHash, clientIdHash: this.clientIdHash,
from: input.from, from: input.from,
to: input.to, to: input.to,
event: event.type, event: event.type,
@@ -782,9 +782,9 @@ export class HostRuntimeController {
} }
const requestVersion = ++this.switchRequestVersion; const requestVersion = ++this.switchRequestVersion;
let clientSessionKey: string; let clientId: string;
try { try {
clientSessionKey = await this.resolveClientSessionKey(); clientId = await this.resolveClientId();
} catch (error) { } catch (error) {
if (!this.isCurrentSwitchRequest(requestVersion)) { if (!this.isCurrentSwitchRequest(requestVersion)) {
return; return;
@@ -792,7 +792,7 @@ export class HostRuntimeController {
const message = toErrorMessage(error); const message = toErrorMessage(error);
this.applyConnectionEvent({ this.applyConnectionEvent({
type: "connect_failed", type: "connect_failed",
message: `Failed to resolve client session key: ${message}`, message: `Failed to resolve client id: ${message}`,
}); });
this.updateSnapshot({ this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState), ...toSnapshotConnectionPatch(this.connectionMachineState),
@@ -827,7 +827,7 @@ export class HostRuntimeController {
const client = this.deps.createClient({ const client = this.deps.createClient({
host: this.host, host: this.host,
connection, connection,
clientSessionKey, clientId,
runtimeGeneration: nextGeneration, runtimeGeneration: nextGeneration,
}); });
if (!this.isCurrentSwitchRequest(requestVersion)) { if (!this.isCurrentSwitchRequest(requestVersion)) {
@@ -910,14 +910,14 @@ export class HostRuntimeController {
} }
} }
private resolveClientSessionKey(): Promise<string> { private resolveClientId(): Promise<string> {
if (!this.clientSessionKeyPromise) { if (!this.clientIdPromise) {
this.clientSessionKeyPromise = this.deps.getClientSessionKey().then((value) => { this.clientIdPromise = this.deps.getClientId().then((value) => {
this.clientSessionKeyHash = hashForLog(value); this.clientIdHash = hashForLog(value);
return 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", () => { it("exposes shell summary from running input before completion", () => {
const callId = "running-summary-shell"; const callId = "running-summary-shell";
const state = hydrateStreamState([ const state = hydrateStreamState([
@@ -9,35 +9,35 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock, default: asyncStorageMock,
})); }));
describe("client-session-key", () => { describe("client-id", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
asyncStorageMock.getItem.mockReset(); asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset(); asyncStorageMock.setItem.mockReset();
}); });
it("returns stored client session key when present", async () => { it("returns stored client id when present", async () => {
asyncStorageMock.getItem.mockResolvedValue("clsk_existing"); asyncStorageMock.getItem.mockResolvedValue("cid_existing");
const mod = await import("./client-session-key"); const mod = await import("./client-id");
const key = await mod.getOrCreateClientSessionKey(); const key = await mod.getOrCreateClientId();
expect(key).toBe("clsk_existing"); expect(key).toBe("cid_existing");
expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1); expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1);
expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); 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.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue(); asyncStorageMock.setItem.mockResolvedValue();
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab"); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
const mod = await import("./client-session-key"); const mod = await import("./client-id");
const key = await mod.getOrCreateClientSessionKey(); const key = await mod.getOrCreateClientId();
expect(key).toBe("clsk_123456781234123412341234567890ab"); expect(key).toBe("cid_123456781234123412341234567890ab");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith( expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:client-session-key-v1", "@paseo:client-id-v1",
"clsk_123456781234123412341234567890ab" "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: { export function buildRelayWebSocketUrl(params: {
endpoint: string; endpoint: string;
serverId: string; serverId: string;
clientSessionKey?: string;
}): string { }): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" }); return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
} }
@@ -1,21 +1,19 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => { const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = []; const createdConfigs: Array<{ clientId?: string; url?: string }> = [];
class MockDaemonClient { class MockDaemonClient {
private statusHandlers = new Set<
(
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
>();
public lastError: string | null = null; 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); createdConfigs.push(config);
} }
@@ -23,35 +21,16 @@ const daemonClientMock = vi.hoisted(() => {
return () => undefined; return () => undefined;
} }
on( on(): () => void {
event: "status", return () => undefined;
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);
};
} }
async connect(): Promise<void> { async connect(): Promise<void> {
const message = { return;
type: "status" as const, }
payload: {
status: "server_info", getLastWelcomeMessage() {
serverId: "srv_probe_test", return this.lastWelcome;
hostname: "probe-host",
},
};
for (const handler of this.statusHandlers) {
handler(message);
}
} }
async ping(): Promise<{ rttMs: number }> { async ping(): Promise<{ rttMs: number }> {
@@ -78,7 +57,7 @@ describe("test-daemon-connection probe client identity", () => {
daemonClientMock.createdConfigs.length = 0; 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"); const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({ await mod.measureConnectionLatency({
@@ -93,8 +72,8 @@ describe("test-daemon-connection probe client identity", () => {
}); });
const [first, second] = daemonClientMock.createdConfigs; const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientSessionKey).toMatch(/^clsk_probe_/); expect(first?.clientId).toMatch(/^cid_probe_/);
expect(second?.clientSessionKey).toMatch(/^clsk_probe_/); expect(second?.clientId).toMatch(/^cid_probe_/);
expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey); expect(first?.clientId).not.toBe(second?.clientId);
}); });
}); });
@@ -1,10 +1,9 @@
import { DaemonClient } from "@server/client/daemon-client"; import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } 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 type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientSessionKey(): string { function createProbeClientId(): string {
const randomUuid = (() => { const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") { 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 `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})(); })();
return `clsk_probe_${randomUuid}`; return `cid_probe_${randomUuid}`;
} }
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
@@ -55,10 +54,11 @@ async function buildClientConfig(
connection: HostConnection, connection: HostConnection,
serverId?: string serverId?: string
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientSessionKey = createProbeClientSessionKey(); const clientId = createProbeClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = { const base = {
clientSessionKey, clientId,
clientType: "mobile" as const,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}), ...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
}; };
@@ -66,7 +66,7 @@ async function buildClientConfig(
if (connection.type === "direct") { if (connection.type === "direct") {
return { return {
...base, ...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }), url: buildDaemonWebSocketUrl(connection.endpoint),
}; };
} }
@@ -79,7 +79,6 @@ async function buildClientConfig(
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint, endpoint: connection.relayEndpoint,
serverId, serverId,
clientSessionKey,
}), }),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
}; };
@@ -92,35 +91,9 @@ function connectAndProbe(
const client = new DaemonClient(config); const client = new DaemonClient(config);
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => { 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(() => { const timer = setTimeout(() => {
finishErr( void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Connection timed out", { new DaemonConnectionTestError("Connection timed out", {
reason: "Connection timed out", reason: "Connection timed out",
lastError: client.lastError ?? null, lastError: client.lastError ?? null,
@@ -128,25 +101,32 @@ function connectAndProbe(
); );
}, timeoutMs); }, timeoutMs);
unsubscribe = client.subscribeConnectionStatus((state) => { void client.connect().then(() => {
if (state.status === "disconnected") { clearTimeout(timer);
const reason = normalizeNonEmptyString(state.reason); const welcome = client.getLastWelcomeMessage();
const lastError = normalizeNonEmptyString(client.lastError); if (!welcome) {
const message = pickBestReason(reason, lastError); void client.close().catch(() => undefined);
finishErr(new DaemonConnectionTestError(message, { reason, lastError })); 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({ const display = buildToolCallDisplayModel({
name: "task", name: "task",
status: "running", status: "running",
error: null, error: null,
detail: { detail: {
type: "unknown", type: "sub_agent",
input: null, subAgentType: "Explore",
output: null, description: "Inspect repository structure",
}, log: "[Read] README.md",
metadata: { actions: [
subAgentActivity: "Running tests", {
index: 1,
toolName: "Read",
summary: "README.md",
},
],
}, },
}); });
expect(display).toEqual({ expect(display).toEqual({
displayName: "Task", displayName: "Explore",
summary: "Running tests", 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, write: Pencil,
search: Search, search: Search,
worktree_setup: SquareTerminal, worktree_setup: SquareTerminal,
sub_agent: Bot,
unknown: Wrench, unknown: Wrench,
}; };
@@ -33,13 +34,12 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
if (lowerName === "speak") { if (lowerName === "speak") {
return MicVocal; return MicVocal;
} }
if (lowerName === "task") {
return Bot;
}
if (detail) { if (detail) {
return TOOL_DETAIL_ICONS[detail.type]; return TOOL_DETAIL_ICONS[detail.type];
} }
if (lowerName === "task") {
return Bot;
}
return Wrench; return Wrench;
} }
@@ -5,31 +5,31 @@ import { homedir } from "node:os";
const CLIENT_SESSION_KEY_FILE = join( const CLIENT_SESSION_KEY_FILE = join(
process.env.PASEO_HOME ?? join(homedir(), ".paseo"), process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
"cli-client-session-key" "cli-client-id"
); );
let cachedClientSessionKey: string | null = null; let cachedClientId: string | null = null;
function normalizeClientSessionKey(value: string): string | null { function normalizeClientId(value: string): string | null {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function generateClientSessionKey(): string { function generateClientId(): string {
return `clsk_${randomUUID().replace(/-/g, "")}`; return `cid_${randomUUID().replace(/-/g, "")}`;
} }
export async function getOrCreateCliClientSessionKey(): Promise<string> { export async function getOrCreateCliClientId(): Promise<string> {
if (cachedClientSessionKey) { if (cachedClientId) {
return cachedClientSessionKey; return cachedClientId;
} }
try { try {
const existing = normalizeClientSessionKey( const existing = normalizeClientId(
await readFile(CLIENT_SESSION_KEY_FILE, "utf8") await readFile(CLIENT_SESSION_KEY_FILE, "utf8")
); );
if (existing) { if (existing) {
cachedClientSessionKey = existing; cachedClientId = existing;
return existing; return existing;
} }
} catch (error) { } catch (error) {
@@ -39,9 +39,9 @@ export async function getOrCreateCliClientSessionKey(): Promise<string> {
} }
} }
const nextValue = generateClientSessionKey(); const nextValue = generateClientId();
await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true }); await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true });
await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 }); await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 });
cachedClientSessionKey = nextValue; cachedClientId = nextValue;
return nextValue; return nextValue;
} }
+12 -9
View File
@@ -1,6 +1,6 @@
import { DaemonClient } from '@getpaseo/server' import { DaemonClient } from '@getpaseo/server'
import WebSocket from 'ws' import WebSocket from 'ws'
import { getOrCreateCliClientSessionKey } from './client-session-key.js' import { getOrCreateCliClientId } from './client-id.js'
export interface ConnectOptions { export interface ConnectOptions {
host?: string host?: string
@@ -40,15 +40,18 @@ function createNodeWebSocketFactory() {
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> { export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options) const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientSessionKey = await getOrCreateCliClientSessionKey() const clientId = await getOrCreateCliClientId()
const encodedSessionKey = encodeURIComponent(clientSessionKey) const url = `ws://${host}/ws`
const url = `ws://${host}/ws?clientSessionKey=${encodedSessionKey}`
const client = new DaemonClient({ const client = new DaemonClient(
url, {
webSocketFactory: createNodeWebSocketFactory(), url,
reconnect: { enabled: false }, clientId,
}) clientType: 'cli',
webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
// Connect with timeout // Connect with timeout
const connectPromise = client.connect() const connectPromise = client.connect()
+24 -16
View File
@@ -69,7 +69,7 @@ async function withMockWebSocketPair(
} }
describe("RelayDurableObject versioning", () => { describe("RelayDurableObject versioning", () => {
it("accepts legacy v1 client sockets without clientId", async () => { it("accepts legacy v1 client sockets without connectionId", async () => {
const { state } = createMockState(); const { state } = createMockState();
await withMockWebSocketPair(async () => { await withMockWebSocketPair(async () => {
const relay = new RelayDurableObject(state as any); const relay = new RelayDurableObject(state as any);
@@ -83,13 +83,21 @@ describe("RelayDurableObject versioning", () => {
}); });
}); });
it("rejects v2 client sockets when clientId is missing", async () => { it("assigns a connectionId when v2 client connects without one", async () => {
const { state } = createMockState(); const { state } = createMockState();
const relay = new RelayDurableObject(state as any); await withMockWebSocketPair(async ({ serverWs }) => {
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2"); const relay = new RelayDurableObject(state as any);
const response = await relay.fetch(req); const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2", {
expect(response.status).toBe(400); headers: { Upgrade: "websocket" },
await expect(response.text()).resolves.toBe("Missing clientId parameter"); });
await relay.fetch(req).catch(() => undefined);
expect(state.acceptWebSocket).toHaveBeenCalled();
const attachment = serverWs.deserializeAttachment();
expect(attachment).toMatchObject({
role: "client",
connectionId: expect.stringMatching(/^conn_/),
});
});
}); });
}); });
@@ -110,7 +118,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
setTagSockets(`server:${clientId}`, []); setTagSockets(`server:${clientId}`, []);
const relay = new RelayDurableObject(state as any); const relay = new RelayDurableObject(state as any);
(relay as any).nudgeOrResetControlForClient(clientId); (relay as any).nudgeOrResetControlForConnection(clientId);
vi.advanceTimersByTime(15_000); vi.advanceTimersByTime(15_000);
@@ -124,7 +132,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
const control = createMockSocket(); const control = createMockSocket();
const client = createMockSocket({ const client = createMockSocket({
role: "client", role: "client",
clientId, connectionId: clientId,
serverId: "srv_test", serverId: "srv_test",
createdAt: Date.now(), createdAt: Date.now(),
}); });
@@ -136,7 +144,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
setTagSockets(`server:${clientId}`, []); setTagSockets(`server:${clientId}`, []);
const relay = new RelayDurableObject(state as any); const relay = new RelayDurableObject(state as any);
(relay as any).nudgeOrResetControlForClient(clientId); (relay as any).nudgeOrResetControlForConnection(clientId);
vi.advanceTimersByTime(10_000); vi.advanceTimersByTime(10_000);
expect(control.send).toHaveBeenCalledTimes(1); expect(control.send).toHaveBeenCalledTimes(1);
@@ -145,11 +153,11 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive"); expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive");
}); });
it("does not replace existing client sockets for the same clientId", async () => { it("does not replace existing client sockets for the same connectionId", async () => {
const existingClient = createMockSocket({ const existingClient = createMockSocket({
version: "2", version: "2",
role: "client", role: "client",
clientId: "clt_same_session", connectionId: "clt_same_session",
serverId: "srv_test", serverId: "srv_test",
createdAt: Date.now(), createdAt: Date.now(),
}); });
@@ -160,7 +168,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
await withMockWebSocketPair(async () => { await withMockWebSocketPair(async () => {
const relay = new RelayDurableObject(state as any); const relay = new RelayDurableObject(state as any);
const req = new Request( const req = new Request(
"https://relay.test/ws?role=client&serverId=srv_test&clientId=clt_same_session&v=2", "https://relay.test/ws?role=client&serverId=srv_test&connectionId=clt_same_session&v=2",
{ {
headers: { headers: {
Upgrade: "websocket", Upgrade: "websocket",
@@ -178,14 +186,14 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
const disconnectedClient = createMockSocket({ const disconnectedClient = createMockSocket({
version: "2", version: "2",
role: "client", role: "client",
clientId, connectionId: clientId,
serverId: "srv_test", serverId: "srv_test",
createdAt: Date.now(), createdAt: Date.now(),
}); });
const stillConnectedClient = createMockSocket({ const stillConnectedClient = createMockSocket({
version: "2", version: "2",
role: "client", role: "client",
clientId, connectionId: clientId,
serverId: "srv_test", serverId: "srv_test",
createdAt: Date.now(), createdAt: Date.now(),
}); });
@@ -208,7 +216,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
expect(serverData.close).not.toHaveBeenCalled(); expect(serverData.close).not.toHaveBeenCalled();
expect(control.send).not.toHaveBeenCalledWith( expect(control.send).not.toHaveBeenCalledWith(
JSON.stringify({ type: "client_disconnected", clientId }) JSON.stringify({ type: "disconnected", connectionId: clientId })
); );
}); });
}); });
+66 -66
View File
@@ -74,9 +74,9 @@ interface DurableObjectStub {
* - role=client: app/client socket * - role=client: app/client socket
* *
* v2 WebSockets connect in three shapes: * v2 WebSockets connect in three shapes:
* - role=server (no clientId): daemon control socket (one per serverId) * - role=server (no connectionId): daemon control socket (one per serverId)
* - role=server&clientId=...: daemon per-client data socket (one per clientId) * - role=server&connectionId=...: daemon per-connection data socket (one per connectionId)
* - role=client&clientId=...: app/client socket (many per clientId) * - role=client&connectionId=...: app/client socket (many per connectionId)
*/ */
interface CFResponseInit extends ResponseInit { interface CFResponseInit extends ResponseInit {
webSocket?: WebSocket; webSocket?: WebSocket;
@@ -84,7 +84,7 @@ interface CFResponseInit extends ResponseInit {
export class RelayDurableObject { export class RelayDurableObject {
private state: DurableObjectState; private state: DurableObjectState;
private pendingClientFrames = new Map<string, Array<string | ArrayBuffer>>(); private pendingFrames = new Map<string, Array<string | ArrayBuffer>>();
constructor(state: DurableObjectState) { constructor(state: DurableObjectState) {
this.state = state; this.state = state;
@@ -110,42 +110,42 @@ export class RelayDurableObject {
} as CFResponseInit); } as CFResponseInit);
} }
private hasServerDataSocket(clientId: string): boolean { private hasServerDataSocket(connectionId: string): boolean {
try { try {
return this.state.getWebSockets(`server:${clientId}`).length > 0; return this.state.getWebSockets(`server:${connectionId}`).length > 0;
} catch { } catch {
return false; return false;
} }
} }
private hasClientSocket(clientId: string): boolean { private hasClientSocket(connectionId: string): boolean {
try { try {
return this.state.getWebSockets(`client:${clientId}`).length > 0; return this.state.getWebSockets(`client:${connectionId}`).length > 0;
} catch { } catch {
return false; return false;
} }
} }
private nudgeOrResetControlForClient(clientId: string): void { private nudgeOrResetControlForConnection(connectionId: string): void {
// If the daemon's control WS becomes half-open, the DO can't reliably detect it via ws.send errors // If the daemon's control WS becomes half-open, the DO can't reliably detect it via ws.send errors
// (Cloudflare may accept writes even if the other side is no longer reading). // (Cloudflare may accept writes even if the other side is no longer reading).
// //
// Instead, observe whether the daemon reacts by opening the per-client server-data socket. // Instead, observe whether the daemon reacts by opening the per-connection server-data socket.
// If it doesn't, nudge with a sync message; if still no reaction, force-close the control // If it doesn't, nudge with a sync message; if still no reaction, force-close the control
// socket(s) so the daemon reconnects. // socket(s) so the daemon reconnects.
const initialDelayMs = 10_000; const initialDelayMs = 10_000;
const secondDelayMs = 5_000; const secondDelayMs = 5_000;
setTimeout(() => { setTimeout(() => {
if (!this.hasClientSocket(clientId)) return; if (!this.hasClientSocket(connectionId)) return;
if (this.hasServerDataSocket(clientId)) return; if (this.hasServerDataSocket(connectionId)) return;
// First nudge: send a full sync list. // First nudge: send a full sync list.
this.notifyControls({ type: "sync", clientIds: this.listConnectedClientIds() }); this.notifyControls({ type: "sync", connectionIds: this.listConnectedConnectionIds() });
setTimeout(() => { setTimeout(() => {
if (!this.hasClientSocket(clientId)) return; if (!this.hasClientSocket(connectionId)) return;
if (this.hasServerDataSocket(clientId)) return; if (this.hasServerDataSocket(connectionId)) return;
// Still nothing: assume control is stuck and force a reconnect. // Still nothing: assume control is stuck and force a reconnect.
for (const ws of this.state.getWebSockets("server-control")) { for (const ws of this.state.getWebSockets("server-control")) {
@@ -159,38 +159,38 @@ export class RelayDurableObject {
}, initialDelayMs); }, initialDelayMs);
} }
private bufferClientFrame(clientId: string, message: string | ArrayBuffer): void { private bufferFrame(connectionId: string, message: string | ArrayBuffer): void {
const existing = this.pendingClientFrames.get(clientId) ?? []; const existing = this.pendingFrames.get(connectionId) ?? [];
existing.push(message); existing.push(message);
// Prevent unbounded memory growth if a daemon never connects. // Prevent unbounded memory growth if a daemon never connects.
if (existing.length > 200) { if (existing.length > 200) {
existing.splice(0, existing.length - 200); existing.splice(0, existing.length - 200);
} }
this.pendingClientFrames.set(clientId, existing); this.pendingFrames.set(connectionId, existing);
} }
private flushClientFrames(clientId: string, serverWs: WebSocket): void { private flushFrames(connectionId: string, serverWs: WebSocket): void {
const frames = this.pendingClientFrames.get(clientId); const frames = this.pendingFrames.get(connectionId);
if (!frames || frames.length === 0) return; if (!frames || frames.length === 0) return;
this.pendingClientFrames.delete(clientId); this.pendingFrames.delete(connectionId);
for (const frame of frames) { for (const frame of frames) {
try { try {
serverWs.send(frame); serverWs.send(frame);
} catch { } catch {
// If we can't flush, re-buffer and let the daemon re-establish. // If we can't flush, re-buffer and let the daemon re-establish.
this.bufferClientFrame(clientId, frame); this.bufferFrame(connectionId, frame);
break; break;
} }
} }
} }
private listConnectedClientIds(): string[] { private listConnectedConnectionIds(): string[] {
const out = new Set<string>(); const out = new Set<string>();
for (const ws of this.state.getWebSockets("client")) { for (const ws of this.state.getWebSockets("client")) {
try { try {
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null; const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
if (attachment?.role === "client" && typeof attachment.clientId === "string" && attachment.clientId) { if (attachment?.role === "client" && typeof attachment.connectionId === "string" && attachment.connectionId) {
out.add(attachment.clientId); out.add(attachment.connectionId);
} }
} catch { } catch {
// ignore // ignore
@@ -230,7 +230,7 @@ export class RelayDurableObject {
serverId, serverId,
role, role,
version: LEGACY_RELAY_VERSION, version: LEGACY_RELAY_VERSION,
clientId: null, connectionId: null,
createdAt: Date.now(), createdAt: Date.now(),
}; };
(server as WebSocketWithAttachment).serializeAttachment(attachment); (server as WebSocketWithAttachment).serializeAttachment(attachment);
@@ -244,30 +244,30 @@ export class RelayDurableObject {
request: Request, request: Request,
role: ConnectionRole, role: ConnectionRole,
serverId: string, serverId: string,
clientId: string connectionId: string
): Response { ): Response {
// Clients must provide a clientId so the daemon can create an independent
// E2EE channel per client connection.
if (role === "client" && !clientId) {
return new Response("Missing clientId parameter", { status: 400 });
}
const upgradeError = this.requireWebSocketUpgrade(request); const upgradeError = this.requireWebSocketUpgrade(request);
if (upgradeError) return upgradeError; if (upgradeError) return upgradeError;
const isServerControl = role === "server" && !clientId; // If a client didn't provide a connectionId, the relay assigns one for routing.
const isServerData = role === "server" && !!clientId; const resolvedConnectionId =
role === "client" && !connectionId
? `conn_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`
: connectionId;
const isServerControl = role === "server" && !resolvedConnectionId;
const isServerData = role === "server" && !!resolvedConnectionId;
// Close any existing server-side connection with the same identity. // Close any existing server-side connection with the same identity.
// - server-control: single per serverId // - server-control: single per serverId
// - server-data: single per clientId // - server-data: single per connectionId
// - client: many sockets per clientId are allowed // - client: many sockets per connectionId are allowed
if (isServerControl) { if (isServerControl) {
for (const ws of this.state.getWebSockets("server-control")) { for (const ws of this.state.getWebSockets("server-control")) {
ws.close(1008, "Replaced by new connection"); ws.close(1008, "Replaced by new connection");
} }
} else if (isServerData) { } else if (isServerData) {
for (const ws of this.state.getWebSockets(`server:${clientId}`)) { for (const ws of this.state.getWebSockets(`server:${resolvedConnectionId}`)) {
ws.close(1008, "Replaced by new connection"); ws.close(1008, "Replaced by new connection");
} }
} }
@@ -276,11 +276,11 @@ export class RelayDurableObject {
const tags: string[] = []; const tags: string[] = [];
if (role === "client") { if (role === "client") {
tags.push("client", `client:${clientId}`); tags.push("client", `client:${resolvedConnectionId}`);
} else if (isServerControl) { } else if (isServerControl) {
tags.push("server-control"); tags.push("server-control");
} else { } else {
tags.push("server", `server:${clientId}`); tags.push("server", `server:${resolvedConnectionId}`);
} }
this.state.acceptWebSocket(server, tags); this.state.acceptWebSocket(server, tags);
@@ -289,31 +289,31 @@ export class RelayDurableObject {
serverId, serverId,
role, role,
version: CURRENT_RELAY_VERSION, version: CURRENT_RELAY_VERSION,
clientId: clientId || null, connectionId: resolvedConnectionId || null,
createdAt: Date.now(), createdAt: Date.now(),
}; };
(server as WebSocketWithAttachment).serializeAttachment(attachment); (server as WebSocketWithAttachment).serializeAttachment(attachment);
console.log( console.log(
`[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${clientId})` : role === "client" ? `(${clientId})` : ""} connected to session ${serverId}` `[Relay DO] v2:${role}${isServerControl ? "(control)" : ""}${isServerData ? `(data:${resolvedConnectionId})` : role === "client" ? `(${resolvedConnectionId})` : ""} connected to session ${serverId}`
); );
if (role === "client") { if (role === "client") {
this.notifyControls({ type: "client_connected", clientId }); this.notifyControls({ type: "connected", connectionId: resolvedConnectionId });
this.nudgeOrResetControlForClient(clientId); this.nudgeOrResetControlForConnection(resolvedConnectionId);
} }
if (isServerControl) { if (isServerControl) {
// Send current client list so the daemon can attach existing clients. // Send current connection list so the daemon can attach existing connections.
try { try {
server.send(JSON.stringify({ type: "sync", clientIds: this.listConnectedClientIds() })); server.send(JSON.stringify({ type: "sync", connectionIds: this.listConnectedConnectionIds() }));
} catch { } catch {
// ignore // ignore
} }
} }
if (isServerData && clientId) { if (isServerData && resolvedConnectionId) {
this.flushClientFrames(clientId, server); this.flushFrames(resolvedConnectionId, server);
} }
return this.asSwitchingProtocolsResponse(client); return this.asSwitchingProtocolsResponse(client);
@@ -323,8 +323,8 @@ export class RelayDurableObject {
const url = new URL(request.url); const url = new URL(request.url);
const role = url.searchParams.get("role") as ConnectionRole | null; const role = url.searchParams.get("role") as ConnectionRole | null;
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const clientIdRaw = url.searchParams.get("clientId"); const connectionIdRaw = url.searchParams.get("connectionId");
const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : ""; const connectionId = typeof connectionIdRaw === "string" ? connectionIdRaw.trim() : "";
const version = resolveRelayVersion(url.searchParams.get("v")); const version = resolveRelayVersion(url.searchParams.get("v"));
if (!role || (role !== "server" && role !== "client")) { if (!role || (role !== "server" && role !== "client")) {
@@ -343,7 +343,7 @@ export class RelayDurableObject {
return this.fetchV1(request, role, serverId); return this.fetchV1(request, role, serverId);
} }
return this.fetchV2(request, role, serverId, clientId); return this.fetchV2(request, role, serverId, connectionId);
} }
/** /**
@@ -371,8 +371,8 @@ export class RelayDurableObject {
return; return;
} }
const { role, clientId } = attachment; const { role, connectionId } = attachment;
if (!clientId) { if (!connectionId) {
// Control channel: support simple app-level keepalive. // Control channel: support simple app-level keepalive.
if (typeof message === "string") { if (typeof message === "string") {
try { try {
@@ -392,28 +392,28 @@ export class RelayDurableObject {
} }
if (role === "client") { if (role === "client") {
const servers = this.state.getWebSockets(`server:${clientId}`); const servers = this.state.getWebSockets(`server:${connectionId}`);
if (servers.length === 0) { if (servers.length === 0) {
this.bufferClientFrame(clientId, message); this.bufferFrame(connectionId, message);
return; return;
} }
for (const target of servers) { for (const target of servers) {
try { try {
target.send(message); target.send(message);
} catch (error) { } catch (error) {
console.error(`[Relay DO] Failed to forward client->server(${clientId}):`, error); console.error(`[Relay DO] Failed to forward client->server(${connectionId}):`, error);
} }
} }
return; return;
} }
// server data socket -> client // server data socket -> client
const targets = this.state.getWebSockets(`client:${clientId}`); const targets = this.state.getWebSockets(`client:${connectionId}`);
for (const target of targets) { for (const target of targets) {
try { try {
target.send(message); target.send(message);
} catch (error) { } catch (error) {
console.error(`[Relay DO] Failed to forward server->client(${clientId}):`, error); console.error(`[Relay DO] Failed to forward server->client(${connectionId}):`, error);
} }
} }
} }
@@ -432,37 +432,37 @@ export class RelayDurableObject {
const version = attachment.version ?? LEGACY_RELAY_VERSION; const version = attachment.version ?? LEGACY_RELAY_VERSION;
console.log( console.log(
`[Relay DO] v${version}:${attachment.role}${attachment.clientId ? `(${attachment.clientId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})` `[Relay DO] v${version}:${attachment.role}${attachment.connectionId ? `(${attachment.connectionId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`
); );
if (version === LEGACY_RELAY_VERSION) { if (version === LEGACY_RELAY_VERSION) {
return; return;
} }
if (attachment.role === "client" && attachment.clientId) { if (attachment.role === "client" && attachment.connectionId) {
const remainingClientSockets = this.state const remainingClientSockets = this.state
.getWebSockets(`client:${attachment.clientId}`) .getWebSockets(`client:${attachment.connectionId}`)
.some((socket) => socket !== ws); .some((socket) => socket !== ws);
if (remainingClientSockets) { if (remainingClientSockets) {
return; return;
} }
this.pendingClientFrames.delete(attachment.clientId); this.pendingFrames.delete(attachment.connectionId);
// Last socket for this session closed: now clean up matching server-data socket. // Last socket for this session closed: now clean up matching server-data socket.
for (const serverWs of this.state.getWebSockets(`server:${attachment.clientId}`)) { for (const serverWs of this.state.getWebSockets(`server:${attachment.connectionId}`)) {
try { try {
serverWs.close(1001, "Client disconnected"); serverWs.close(1001, "Client disconnected");
} catch { } catch {
// ignore // ignore
} }
} }
this.notifyControls({ type: "client_disconnected", clientId: attachment.clientId }); this.notifyControls({ type: "disconnected", connectionId: attachment.connectionId });
return; return;
} }
if (attachment.role === "server" && attachment.clientId) { if (attachment.role === "server" && attachment.connectionId) {
// Force the client to reconnect and re-handshake when the daemon side drops. // Force the client to reconnect and re-handshake when the daemon side drops.
for (const clientWs of this.state.getWebSockets(`client:${attachment.clientId}`)) { for (const clientWs of this.state.getWebSockets(`client:${attachment.connectionId}`)) {
try { try {
clientWs.close(1012, "Server disconnected"); clientWs.close(1012, "Server disconnected");
} catch { } catch {
+13 -13
View File
@@ -133,7 +133,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => { it("full flow: daemon and client exchange encrypted messages through relay", { timeout: 90_000 }, async () => {
const serverId = "test-session-" + Date.now(); const serverId = "test-session-" + Date.now();
const clientId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2); const connectionId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
// === DAEMON SIDE === // === DAEMON SIDE ===
// Generate keypair (public key goes in QR) // Generate keypair (public key goes in QR)
@@ -167,7 +167,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const waitForClientSeen = new Promise<void>((resolve, reject) => { const waitForClientSeen = new Promise<void>((resolve, reject) => {
const timeout = setTimeout( const timeout = setTimeout(
() => reject(new Error("timed out waiting for client_connected")), () => reject(new Error("timed out waiting for connected")),
5000 5000
); );
const onMessage = (raw: unknown) => { const onMessage = (raw: unknown) => {
@@ -179,13 +179,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
? (raw as any).toString() ? (raw as any).toString()
: ""; : "";
const msg = JSON.parse(text); const msg = JSON.parse(text);
if (msg?.type === "client_connected" && msg.clientId === clientId) { if (msg?.type === "connected" && msg.connectionId === connectionId) {
clearTimeout(timeout); clearTimeout(timeout);
daemonControlWs.off("message", onMessage); daemonControlWs.off("message", onMessage);
resolve(); resolve();
return; return;
} }
if (msg?.type === "sync" && Array.isArray(msg.clientIds) && msg.clientIds.includes(clientId)) { if (msg?.type === "sync" && Array.isArray(msg.connectionIds) && msg.connectionIds.includes(connectionId)) {
clearTimeout(timeout); clearTimeout(timeout);
daemonControlWs.off("message", onMessage); daemonControlWs.off("message", onMessage);
resolve(); resolve();
@@ -197,9 +197,9 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
daemonControlWs.on("message", onMessage); daemonControlWs.on("message", onMessage);
}); });
// Client connects to relay as "client" role (must include clientId) // Client connects to relay as "client" role (must include connectionId)
const clientWs = new WebSocket( const clientWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2` `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`
); );
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -210,7 +210,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
await waitForClientSeen; await waitForClientSeen;
const daemonWs = new WebSocket( const daemonWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2` `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`
); );
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
daemonWs.on("open", resolve); daemonWs.on("open", resolve);
@@ -300,7 +300,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
it("relay only sees opaque bytes after handshake", { timeout: 90_000 }, async () => { it("relay only sees opaque bytes after handshake", { timeout: 90_000 }, async () => {
const serverId = "opaque-test-" + Date.now(); const serverId = "opaque-test-" + Date.now();
const clientId = "clt_opaque_" + Date.now() + "_" + Math.random().toString(36).slice(2); const connectionId = "clt_opaque_" + Date.now() + "_" + Math.random().toString(36).slice(2);
// Setup keys // Setup keys
const daemonKeyPair = await generateKeyPair(); const daemonKeyPair = await generateKeyPair();
@@ -328,7 +328,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const waitForClientSeen = new Promise<void>((resolve, reject) => { const waitForClientSeen = new Promise<void>((resolve, reject) => {
const timeout = setTimeout( const timeout = setTimeout(
() => reject(new Error("timed out waiting for client_connected")), () => reject(new Error("timed out waiting for connected")),
5000 5000
); );
const onMessage = (raw: unknown) => { const onMessage = (raw: unknown) => {
@@ -340,13 +340,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
? (raw as any).toString() ? (raw as any).toString()
: ""; : "";
const msg = JSON.parse(text); const msg = JSON.parse(text);
if (msg?.type === "client_connected" && msg.clientId === clientId) { if (msg?.type === "connected" && msg.connectionId === connectionId) {
clearTimeout(timeout); clearTimeout(timeout);
daemonControlWs.off("message", onMessage); daemonControlWs.off("message", onMessage);
resolve(); resolve();
return; return;
} }
if (msg?.type === "sync" && Array.isArray(msg.clientIds) && msg.clientIds.includes(clientId)) { if (msg?.type === "sync" && Array.isArray(msg.connectionIds) && msg.connectionIds.includes(connectionId)) {
clearTimeout(timeout); clearTimeout(timeout);
daemonControlWs.off("message", onMessage); daemonControlWs.off("message", onMessage);
resolve(); resolve();
@@ -359,13 +359,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}); });
const clientWs = new WebSocket( const clientWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&clientId=${clientId}&v=2` `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`
); );
await new Promise<void>((r) => clientWs.on("open", r)); await new Promise<void>((r) => clientWs.on("open", r));
await waitForClientSeen; await waitForClientSeen;
const daemonWs = new WebSocket( const daemonWs = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&clientId=${clientId}&v=2` `ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`
); );
await new Promise<void>((r) => daemonWs.on("open", r)); await new Promise<void>((r) => daemonWs.on("open", r));
+5 -5
View File
@@ -36,14 +36,14 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
await withRetry( await withRetry(
async () => { async () => {
const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`; const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const clientId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`; const connectionId = `clt_live_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server&v=2`; const serverControlUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server&v=2`;
const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent( const serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId serverId
)}&role=server&clientId=${encodeURIComponent(clientId)}&v=2`; )}&role=server&connectionId=${encodeURIComponent(connectionId)}&v=2`;
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent( const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
serverId serverId
)}&role=client&clientId=${encodeURIComponent(clientId)}&v=2`; )}&role=client&connectionId=${encodeURIComponent(connectionId)}&v=2`;
// === Key setup === // === Key setup ===
const daemonKeyPair = await generateKeyPair(); const daemonKeyPair = await generateKeyPair();
@@ -87,13 +87,13 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = setTimeout( const timeout = setTimeout(
() => reject(new Error("Timed out waiting for client_connected")), () => reject(new Error("Timed out waiting for connected")),
10_000 10_000
); );
daemonControlWs.on("message", (raw) => { daemonControlWs.on("message", (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString());
if (msg && msg.type === "client_connected" && msg.clientId === clientId) { if (msg && msg.type === "connected" && msg.connectionId === connectionId) {
clearTimeout(timeout); clearTimeout(timeout);
resolve(); resolve();
} }
+3 -3
View File
@@ -20,9 +20,9 @@ export interface RelaySessionAttachment {
*/ */
version?: "1" | "2"; version?: "1" | "2";
/** /**
* Unique id for the client connection. Allows the daemon to create an * Unique id for the connection. Allows the daemon to create an
* independent socket + E2EE channel per connected client. * independent socket + E2EE channel per connected connection.
*/ */
clientId?: string | null; connectionId?: string | null;
createdAt: number; createdAt: number;
} }
@@ -83,7 +83,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -171,7 +172,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -197,7 +199,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
connectTimeoutMs: 100, connectTimeoutMs: 100,
@@ -234,7 +237,8 @@ describe('DaemonClient', () => {
let transportIndex = 0 let transportIndex = 0
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_test&v=2', url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
clientId: 'clsk_test',
logger, logger,
reconnect: { reconnect: {
enabled: true, enabled: true,
@@ -267,42 +271,24 @@ describe('DaemonClient', () => {
} }
}) })
test('requires explicit relay session identity when URL clientId is missing', () => { test('requires non-empty clientId', () => {
expect(() => { expect(() => {
new DaemonClient({ new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2', url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
clientId: '',
reconnect: { enabled: false }, reconnect: { enabled: false },
}) })
}).toThrow('Relay client requires clientSessionKey or URL clientId') }).toThrow('Daemon client requires a non-empty clientId')
}) })
test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => { test('requires non-empty clientId for direct connections', () => {
expect(() => {
new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2',
clientSessionKey: 'clsk_b',
reconnect: { enabled: false },
})
}).toThrow('Relay clientId and clientSessionKey must match when both are provided')
})
test('requires explicit direct session identity when URL clientSessionKey is missing', () => {
expect(() => { expect(() => {
new DaemonClient({ new DaemonClient({
url: 'ws://127.0.0.1:6767/ws', url: 'ws://127.0.0.1:6767/ws',
clientId: ' ',
reconnect: { enabled: false }, reconnect: { enabled: false },
}) })
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey') }).toThrow('Daemon client requires a non-empty clientId')
})
test('rejects direct identity mismatch between URL and config clientSessionKey', () => {
expect(() => {
new DaemonClient({
url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a',
clientSessionKey: 'clsk_b',
reconnect: { enabled: false },
})
}).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
}) })
test('logs configured runtime generation in connection transition events', async () => { test('logs configured runtime generation in connection transition events', async () => {
@@ -310,7 +296,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
runtimeGeneration: 7, runtimeGeneration: 7,
@@ -336,7 +323,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -399,7 +387,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -467,7 +456,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -526,7 +516,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -597,7 +588,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -659,7 +651,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -705,7 +698,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -786,7 +780,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -829,7 +824,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -881,7 +877,8 @@ describe('DaemonClient', () => {
}) })
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory, transportFactory,
@@ -908,7 +905,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -962,7 +960,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1037,7 +1036,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1093,7 +1093,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1139,7 +1140,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1181,7 +1183,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1223,7 +1226,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1285,7 +1289,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1347,7 +1352,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1418,7 +1424,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1470,7 +1477,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1562,7 +1570,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1632,7 +1641,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
@@ -1668,7 +1678,8 @@ describe('DaemonClient', () => {
const mock = createMockTransport() const mock = createMockTransport()
const client = new DaemonClient({ const client = new DaemonClient({
url: 'ws://test?clientSessionKey=clsk_unit_test', url: 'ws://test',
clientId: 'clsk_unit_test',
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
transportFactory: () => mock.transport, transportFactory: () => mock.transport,
+72 -57
View File
@@ -6,6 +6,7 @@ import {
AgentResumedStatusPayloadSchema, AgentResumedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema,
SessionInboundMessageSchema, SessionInboundMessageSchema,
type WSWelcomeMessage,
WSOutboundMessageSchema, WSOutboundMessageSchema,
} from '../shared/messages.js' } from '../shared/messages.js'
import type { import type {
@@ -144,7 +145,8 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
export type DaemonClientConfig = { export type DaemonClientConfig = {
url: string url: string
clientSessionKey?: string clientId: string
clientType?: 'mobile' | 'browser' | 'cli' | 'mcp'
runtimeGeneration?: number | null runtimeGeneration?: number | null
authHeader?: string authHeader?: string
suppressSendErrors?: boolean suppressSendErrors?: boolean
@@ -312,7 +314,7 @@ function isWaiterTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith('Timeout waiting for message') return error instanceof Error && error.message.startsWith('Timeout waiting for message')
} }
function normalizeClientSessionKey(value: unknown): string | null { function normalizeClientId(value: unknown): string | null {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null return null
} }
@@ -390,8 +392,9 @@ export class DaemonClient {
private terminalStreams: TerminalStreamManager private terminalStreams: TerminalStreamManager
private readonly logConnectionPath: 'direct' | 'relay' private readonly logConnectionPath: 'direct' | 'relay'
private readonly logServerId: string | null private readonly logServerId: string | null
private readonly logClientSessionKeyHash: string private readonly logClientIdHash: string
private readonly logGeneration: number | null private readonly logGeneration: number | null
private lastWelcomeMessage: WSWelcomeMessage | null = null
constructor(private config: DaemonClientConfig) { constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger this.logger = config.logger ?? consoleLogger
@@ -402,11 +405,16 @@ export class DaemonClient {
} catch { } catch {
parsedUrlForLog = null parsedUrlForLog = null
} }
const parsedServerIdForLog = normalizeClientSessionKey( const parsedServerIdForLog = normalizeClientId(
parsedUrlForLog?.searchParams.get('serverId') parsedUrlForLog?.searchParams.get('serverId')
) )
this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null
this.logClientSessionKeyHash = 'h_unresolved' const resolvedClientId = normalizeClientId(this.config.clientId)
if (!resolvedClientId) {
throw new Error('Daemon client requires a non-empty clientId')
}
this.config.clientId = resolvedClientId
this.logClientIdHash = hashForLog(resolvedClientId)
this.logGeneration = this.logGeneration =
typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration) typeof this.config.runtimeGeneration === 'number' && Number.isFinite(this.config.runtimeGeneration)
? this.config.runtimeGeneration ? this.config.runtimeGeneration
@@ -422,48 +430,6 @@ export class DaemonClient {
}) })
}, },
}) })
const configClientSessionKey = normalizeClientSessionKey(this.config.clientSessionKey)
let parsed: URL | null = null
try {
parsed = new URL(this.config.url)
} catch {
// ignore - invalid URL will be handled on connect
}
// Relay requires a clientId so the daemon can create an independent
// socket + E2EE channel per connected client.
if (isRelayClientWebSocketUrl(this.config.url)) {
const urlClientId = normalizeClientSessionKey(parsed?.searchParams.get('clientId'))
if (urlClientId && configClientSessionKey && configClientSessionKey !== urlClientId) {
throw new Error('Relay clientId and clientSessionKey must match when both are provided')
}
const resolvedClientSessionKey = configClientSessionKey ?? urlClientId
if (!resolvedClientSessionKey) {
throw new Error('Relay client requires clientSessionKey or URL clientId')
}
this.config.clientSessionKey = resolvedClientSessionKey
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
if (parsed && !urlClientId) {
parsed.searchParams.set('clientId', resolvedClientSessionKey)
this.config.url = parsed.toString()
}
return
}
const urlClientSessionKey = normalizeClientSessionKey(parsed?.searchParams.get('clientSessionKey'))
if (urlClientSessionKey && configClientSessionKey && configClientSessionKey !== urlClientSessionKey) {
throw new Error('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
}
const resolvedClientSessionKey = configClientSessionKey ?? urlClientSessionKey
if (!resolvedClientSessionKey) {
throw new Error('Direct client requires clientSessionKey or URL clientSessionKey')
}
this.config.clientSessionKey = resolvedClientSessionKey
this.logClientSessionKeyHash = hashForLog(resolvedClientSessionKey)
if (parsed && !urlClientSessionKey) {
parsed.searchParams.set('clientSessionKey', resolvedClientSessionKey)
this.config.url = parsed.toString()
}
} }
// ============================================================================ // ============================================================================
@@ -532,8 +498,10 @@ export class DaemonClient {
logger: this.logger, logger: this.logger,
}) })
} }
const transport = transportFactory({ url: this.config.url, headers }) const transportUrl = this.resolveTransportUrlForAttempt()
const transport = transportFactory({ url: transportUrl, headers })
this.transport = transport this.transport = transport
this.lastWelcomeMessage = null
this.updateConnectionState({ this.updateConnectionState({
status: 'connecting', status: 'connecting',
@@ -556,18 +524,12 @@ export class DaemonClient {
this.transportCleanup = [ this.transportCleanup = [
transport.onOpen(() => { transport.onOpen(() => {
this.resetConnectTimeout()
if (this.pendingGenericTransportErrorTimeout) { if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout) clearTimeout(this.pendingGenericTransportErrorTimeout)
this.pendingGenericTransportErrorTimeout = null this.pendingGenericTransportErrorTimeout = null
} }
this.lastErrorValue = null this.lastErrorValue = null
this.reconnectAttempt = 0 this.sendHelloMessage()
this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
this.resubscribeCheckoutDiffSubscriptions()
this.resubscribeTerminalDirectorySubscriptions()
this.flushPendingSendQueue()
this.resolveConnect()
}), }),
transport.onClose((event) => { transport.onClose((event) => {
this.resetConnectTimeout() this.resetConnectTimeout()
@@ -674,6 +636,7 @@ export class DaemonClient {
this.clearWaiters(new Error('Daemon client closed')) this.clearWaiters(new Error('Daemon client closed'))
this.rejectPendingSendQueue(new Error('Daemon client closed')) this.rejectPendingSendQueue(new Error('Daemon client closed'))
this.terminalStreams.clearAll() this.terminalStreams.clearAll()
this.lastWelcomeMessage = null
this.updateConnectionState( this.updateConnectionState(
{ status: 'disposed' }, { status: 'disposed' },
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' } { event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
@@ -2599,6 +2562,45 @@ export class DaemonClient {
return requestId ?? crypto.randomUUID() return requestId ?? crypto.randomUUID()
} }
getLastWelcomeMessage(): WSWelcomeMessage | null {
return this.lastWelcomeMessage
}
private resolveTransportUrlForAttempt(): string {
return this.config.url
}
private sendHelloMessage(): void {
if (!this.transport) {
this.scheduleReconnect({
reason: 'Transport unavailable before hello',
event: 'HELLO_TRANSPORT_MISSING',
reasonCode: 'transport_error',
})
return
}
try {
this.transport.send(
JSON.stringify({
type: 'hello',
clientId: this.config.clientId,
clientType: this.config.clientType ?? 'cli',
protocolVersion: 1,
})
)
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to send hello message'
this.lastErrorValue = message
this.scheduleReconnect({
reason: message,
event: 'HELLO_SEND_FAILED',
reasonCode: 'transport_error',
})
}
}
private disposeTransport(code = 1001, reason = 'Reconnecting'): void { private disposeTransport(code = 1001, reason = 'Reconnecting'): void {
this.cleanupTransport() this.cleanupTransport()
if (this.transport) { if (this.transport) {
@@ -2660,7 +2662,7 @@ export class DaemonClient {
const parsed = WSOutboundMessageSchema.safeParse(parsedJson) const parsed = WSOutboundMessageSchema.safeParse(parsedJson)
if (!parsed.success) { if (!parsed.success) {
const msgType = (parsedJson as { message?: { type?: string } })?.message?.type ?? 'unknown' const msgType = (parsedJson as { type?: string })?.type ?? 'unknown'
this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed') this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed')
return return
} }
@@ -2669,6 +2671,18 @@ export class DaemonClient {
return return
} }
if (parsed.data.type === 'welcome') {
this.lastWelcomeMessage = parsed.data
this.resetConnectTimeout()
this.reconnectAttempt = 0
this.updateConnectionState({ status: 'connected' }, { event: 'HELLO_WELCOME' })
this.resubscribeCheckoutDiffSubscriptions()
this.resubscribeTerminalDirectorySubscriptions()
this.flushPendingSendQueue()
this.resolveConnect()
return
}
this.handleSessionMessage(parsed.data.message) this.handleSessionMessage(parsed.data.message)
} }
@@ -2704,7 +2718,7 @@ export class DaemonClient {
this.logger.info( this.logger.info(
{ {
serverId: this.logServerId, serverId: this.logServerId,
clientSessionKeyHash: this.logClientSessionKeyHash, clientIdHash: this.logClientIdHash,
from: previous.status, from: previous.status,
to: next.status, to: next.status,
event: metadata?.event ?? 'STATE_UPDATE', event: metadata?.event ?? 'STATE_UPDATE',
@@ -2745,6 +2759,7 @@ export class DaemonClient {
this.clearWaiters(new Error(reason ?? 'Connection lost')) this.clearWaiters(new Error(reason ?? 'Connection lost'))
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost')) this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
this.terminalStreams.clearAll() this.terminalStreams.clearAll()
this.lastWelcomeMessage = null
if (wasDisposed) { if (wasDisposed) {
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed')) this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
@@ -128,26 +128,51 @@ describe("curateAgentActivity", () => {
callId: "task-1", callId: "task-1",
name: "Task", name: "Task",
status: "running", status: "running",
input: { description: "Investigate" }, detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Investigate repository",
log: "[Read] README.md",
actions: [
{
index: 1,
toolName: "Read",
summary: "README.md",
},
],
},
}), }),
toolCallItem({ toolCallItem({
callId: "task-1", callId: "task-1",
name: "Task", name: "Task",
status: "running", status: "running",
metadata: { subAgentActivity: "Read" }, detail: {
}), type: "sub_agent",
toolCallItem({ subAgentType: "Explore",
callId: "task-1", description: "Investigate repository",
name: "Task", log: "[Read] README.md\n[Bash] ls",
status: "running", actions: [
metadata: { subAgentActivity: "Edit" }, {
index: 1,
toolName: "Read",
summary: "README.md",
},
{
index: 2,
toolName: "Bash",
summary: "ls",
},
],
},
}), }),
]; ];
const result = curateAgentActivity(timeline); const result = curateAgentActivity(timeline);
const lines = result.split("\n"); const lines = result.split("\n");
expect(lines.filter((line) => line.startsWith("[Task]"))).toEqual(["[Task] Edit"]); expect(lines.filter((line) => line.startsWith("[Explore]"))).toEqual([
"[Explore] Investigate repository",
]);
}); });
it("renders todo/error/compaction entries", () => { it("renders todo/error/compaction entries", () => {
@@ -16,6 +16,63 @@ import type {
AgentStreamEvent, AgentStreamEvent,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class EventPushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
private closed = false;
push(value: T): void {
if (this.closed) {
return;
}
const resolver = this.resolvers.shift();
if (resolver) {
resolver({ value, done: false });
return;
}
this.queue.push(value);
}
end(): void {
this.closed = true;
while (this.resolvers.length > 0) {
const resolver = this.resolvers.shift();
resolver?.({ value: undefined, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
if (this.queue.length > 0) {
const value = this.queue.shift()!;
return Promise.resolve({ value, done: false });
}
if (this.closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => this.resolvers.push(resolve));
},
};
}
}
const TEST_CAPABILITIES = { const TEST_CAPABILITIES = {
supportsStreaming: false, supportsStreaming: false,
supportsSessionPersistence: false, supportsSessionPersistence: false,
@@ -792,6 +849,207 @@ describe("AgentManager", () => {
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex"); expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
}); });
test("waitForAgentEvent does not resolve idle until pendingRun is cleared", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-wait-coherence-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const releaseTurnCompleted = deferred<void>();
const releaseStreamEnd = deferred<void>();
class SlowTerminalSession extends TestAgentSession {
override async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
await releaseTurnCompleted.promise;
yield { type: "turn_completed", provider: this.provider };
await releaseStreamEnd.promise;
}
}
class SlowTerminalClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new SlowTerminalSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new SlowTerminalClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000124",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const turnCompletedSeen = new Promise<void>((resolve) => {
const unsubscribe = manager.subscribe(
(event) => {
if (
event.type === "agent_stream" &&
event.agentId === snapshot.id &&
event.event.type === "turn_completed"
) {
unsubscribe();
resolve();
}
},
{ agentId: snapshot.id, replayState: false }
);
});
const stream = manager.streamAgent(snapshot.id, "hello");
const consumePromise = (async () => {
for await (const _event of stream) {
// Drain events so manager lifecycle progresses naturally.
}
})();
await manager.waitForAgentRunStart(snapshot.id);
const waitPromise = manager.waitForAgentEvent(snapshot.id);
releaseTurnCompleted.resolve();
await turnCompletedSeen;
const earlyResolution = await Promise.race([
waitPromise.then(() => "resolved"),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
]);
expect(earlyResolution).toBe("pending");
releaseStreamEnd.resolve();
const waited = await waitPromise;
expect(waited.status).toBe("idle");
await consumePromise;
});
test("applies live autonomous events while no foreground run is active", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const liveEvents = new EventPushable<AgentStreamEvent>();
class LiveEventSession extends TestAgentSession {
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
for await (const event of liveEvents) {
yield event;
}
}
override async close(): Promise<void> {
liveEvents.end();
}
}
class LiveEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new LiveEventSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new LiveEventClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000125",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const lifecycleUpdates: string[] = [];
let sawRunningState = false;
let resolveSettled!: () => void;
const settled = new Promise<void>((resolve) => {
resolveSettled = resolve;
});
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
lifecycleUpdates.push(event.agent.lifecycle);
if (event.agent.lifecycle === "running") {
sawRunningState = true;
}
if (sawRunningState && event.agent.lifecycle === "idle") {
resolveSettled();
}
}
},
{ agentId: snapshot.id, replayState: false }
);
liveEvents.push({ type: "turn_started", provider: "codex" });
liveEvents.push({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" },
});
liveEvents.push({ type: "turn_completed", provider: "codex" });
await settled;
const updated = manager.getAgent(snapshot.id);
expect(updated?.lifecycle).toBe("idle");
expect(manager.getTimeline(snapshot.id)).toContainEqual({
type: "assistant_message",
text: "AUTONOMOUS_PUMP_MESSAGE",
});
expect(lifecycleUpdates).toContain("running");
expect(lifecycleUpdates).toContain("idle");
});
test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const liveEvents = new EventPushable<AgentStreamEvent>();
class LiveEventSession extends TestAgentSession {
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
for await (const event of liveEvents) {
yield event;
}
}
override async close(): Promise<void> {
liveEvents.end();
}
}
class LiveEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new LiveEventSession(config);
}
}
const manager = new AgentManager({
clients: {
codex: new LiveEventClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000126",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true });
liveEvents.push({ type: "turn_started", provider: "codex" });
liveEvents.push({ type: "turn_completed", provider: "codex" });
const result = await waitPromise;
expect(result.status).toBe("idle");
});
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => { test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -238,6 +238,10 @@ type SubscriptionRecord = {
agentId: string | null; agentId: string | null;
}; };
type LiveEventStreamingSession = AgentSession & {
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
};
const DEFAULT_MAX_TIMELINE_ITEMS = 2000; const DEFAULT_MAX_TIMELINE_ITEMS = 2000;
const DEFAULT_TIMELINE_FETCH_LIMIT = 200; const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
const BUSY_STATUSES: AgentLifecycleStatus[] = [ const BUSY_STATUSES: AgentLifecycleStatus[] = [
@@ -272,6 +276,16 @@ function validateAgentId(agentId: string, source: string): string {
return result.data; return result.data;
} }
function supportsLiveEventStream(
session: AgentSession
): session is LiveEventStreamingSession {
return (
"streamLiveEvents" in session &&
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents ===
"function"
);
}
export class AgentManager { export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>(); private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly agents = new Map<string, ActiveManagedAgent>(); private readonly agents = new Map<string, ActiveManagedAgent>();
@@ -281,6 +295,7 @@ export class AgentManager {
private readonly registry?: AgentStorage; private readonly registry?: AgentStorage;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>(); private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>(); private readonly backgroundTasks = new Set<Promise<void>>();
private readonly liveEventPumps = new Map<string, Promise<void>>();
private onAgentAttention?: AgentAttentionCallback; private onAgentAttention?: AgentAttentionCallback;
private logger: Logger; private logger: Logger;
@@ -727,6 +742,7 @@ export class AgentManager {
// Remove the existing agent entry before swapping sessions // Remove the existing agent entry before swapping sessions
this.agents.delete(agentId); this.agents.delete(agentId);
this.liveEventPumps.delete(agentId);
try { try {
await existing.session.close(); await existing.session.close();
} catch (error) { } catch (error) {
@@ -756,6 +772,7 @@ export class AgentManager {
async closeAgent(agentId: string): Promise<void> { async closeAgent(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
this.agents.delete(agentId); this.agents.delete(agentId);
this.liveEventPumps.delete(agentId);
// Clean up previousStatus to prevent memory leak // Clean up previousStatus to prevent memory leak
this.previousStatuses.delete(agentId); this.previousStatuses.delete(agentId);
const session = agent.session; const session = agent.session;
@@ -1275,6 +1292,7 @@ export class AgentManager {
let currentStatus: AgentLifecycleStatus = initialStatus; let currentStatus: AgentLifecycleStatus = initialStatus;
let hasStarted = initialBusy || hasPendingRun; let hasStarted = initialBusy || hasPendingRun;
let terminalStatusOverride: AgentLifecycleStatus | null = null;
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them // Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
@@ -1337,6 +1355,9 @@ export class AgentManager {
return; return;
} }
if (!waitForActive || hasStarted) { if (!waitForActive || hasStarted) {
if (terminalStatusOverride) {
currentStatus = terminalStatusOverride;
}
finish(null); finish(null);
} }
return; return;
@@ -1348,20 +1369,15 @@ export class AgentManager {
return; return;
} }
if (event.event.type === "turn_failed") { if (event.event.type === "turn_failed") {
currentStatus = "error";
hasStarted = true; hasStarted = true;
finish(null); terminalStatusOverride = "error";
return; return;
} }
if (event.event.type === "turn_completed") { if (event.event.type === "turn_completed") {
currentStatus = "idle";
hasStarted = true; hasStarted = true;
finish(null);
} }
if (event.event.type === "turn_canceled") { if (event.event.type === "turn_canceled") {
currentStatus = "idle";
hasStarted = true; hasStarted = true;
finish(null);
} }
} }
}, },
@@ -1461,6 +1477,7 @@ export class AgentManager {
managed.lifecycle = "idle"; managed.lifecycle = "idle";
await this.persistSnapshot(managed); await this.persistSnapshot(managed);
this.emitState(managed); this.emitState(managed);
this.startLiveEventPump(managed);
return { ...managed }; return { ...managed };
} }
@@ -1619,9 +1636,16 @@ export class AgentManager {
case "turn_completed": case "turn_completed":
agent.lastUsage = event.usage; agent.lastUsage = event.usage;
agent.lastError = undefined; agent.lastError = undefined;
if (!agent.pendingRun && agent.lifecycle !== "idle") {
(agent as ActiveManagedAgent).lifecycle = "idle";
this.emitState(agent);
}
void this.refreshRuntimeInfo(agent); void this.refreshRuntimeInfo(agent);
break; break;
case "turn_failed": case "turn_failed":
if (!agent.pendingRun) {
agent.lifecycle = "error";
}
agent.lastError = event.error; agent.lastError = event.error;
for (const [requestId] of agent.pendingPermissions) { for (const [requestId] of agent.pendingPermissions) {
agent.pendingPermissions.delete(requestId); agent.pendingPermissions.delete(requestId);
@@ -1637,6 +1661,9 @@ export class AgentManager {
this.emitState(agent); this.emitState(agent);
break; break;
case "turn_canceled": case "turn_canceled":
if (!agent.pendingRun) {
(agent as ActiveManagedAgent).lifecycle = "idle";
}
agent.lastError = undefined; agent.lastError = undefined;
for (const [requestId] of agent.pendingPermissions) { for (const [requestId] of agent.pendingPermissions) {
agent.pendingPermissions.delete(requestId); agent.pendingPermissions.delete(requestId);
@@ -1651,6 +1678,12 @@ export class AgentManager {
} }
this.emitState(agent); this.emitState(agent);
break; break;
case "turn_started":
if (!agent.pendingRun) {
(agent as ActiveManagedAgent).lifecycle = "running";
this.emitState(agent);
}
break;
case "permission_requested": case "permission_requested":
agent.pendingPermissions.set(event.request.id, event.request); agent.pendingPermissions.set(event.request.id, event.request);
this.emitState(agent); this.emitState(agent);
@@ -1888,4 +1921,42 @@ export class AgentManager {
} }
return agent; return agent;
} }
private startLiveEventPump(agent: ActiveManagedAgent): void {
if (!supportsLiveEventStream(agent.session)) {
return;
}
const liveSession = agent.session;
if (this.liveEventPumps.has(agent.id)) {
return;
}
const pump = (async () => {
try {
for await (const event of liveSession.streamLiveEvents()) {
const current = this.agents.get(agent.id);
if (!current) {
break;
}
// Foreground streamAgent owns live prompt events while running.
// Background live-event tailing is for autonomous activity while idle.
if (current.pendingRun) {
continue;
}
this.handleStreamEvent(current, event);
}
} catch (error) {
this.logger.warn(
{ err: error, agentId: agent.id },
"Live event pump failed"
);
}
})();
this.liveEventPumps.set(agent.id, pump);
pump.finally(() => {
const current = this.liveEventPumps.get(agent.id);
if (current === pump) {
this.liveEventPumps.delete(agent.id);
}
});
}
} }
@@ -149,6 +149,17 @@ export type ToolCallDetail =
}>; }>;
truncated?: boolean; truncated?: boolean;
} }
| {
type: "sub_agent";
subAgentType?: string;
description?: string;
log: string;
actions: Array<{
index: number;
toolName: string;
summary?: string;
}>;
}
| { | {
type: "unknown"; type: "unknown";
input: unknown | null; input: unknown | null;
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
@@ -50,6 +53,23 @@ function buildUsage() {
}; };
} }
function createPromptUuidReader(prompt: AsyncIterable<unknown>) {
const iterator = prompt[Symbol.asyncIterator]();
let cached: Promise<string | null> | null = null;
return async () => {
if (!cached) {
cached = iterator.next().then((next) => {
if (next.done) {
return null;
}
const value = next.value as { uuid?: unknown } | undefined;
return typeof value?.uuid === "string" ? value.uuid : null;
});
}
return cached;
};
}
function buildFirstQueryMock( function buildFirstQueryMock(
allowOldAssistant: Promise<void> allowOldAssistant: Promise<void>
): QueryMock { ): QueryMock {
@@ -108,7 +128,8 @@ function buildFirstQueryMock(
}; };
} }
function buildSecondQueryMock(): QueryMock { function buildSecondQueryMock(prompt: AsyncIterable<unknown>): QueryMock {
const readPromptUuid = createPromptUuidReader(prompt);
let step = 0; let step = 0;
return { return {
next: vi.fn(async () => { next: vi.fn(async () => {
@@ -126,6 +147,21 @@ function buildSecondQueryMock(): QueryMock {
}; };
} }
if (step === 1) { if (step === 1) {
step += 1;
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
return {
done: false,
value: {
type: "user",
message: { role: "user", content: "second prompt" },
parent_tool_use_id: null,
uuid: promptUuid,
session_id: "interrupt-regression-session",
isReplay: true,
},
};
}
if (step === 2) {
step += 1; step += 1;
return { return {
done: false, done: false,
@@ -137,7 +173,7 @@ function buildSecondQueryMock(): QueryMock {
}, },
}; };
} }
if (step === 2) { if (step === 3) {
step += 1; step += 1;
return { return {
done: false, done: false,
@@ -188,22 +224,49 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
.join(""); .join("");
} }
function createTimedIteratorReader<T>(params: { iterator: AsyncIterator<T> }) {
const { iterator } = params;
let pendingNext: Promise<IteratorResult<T>> | null = null;
return {
async nextWithTimeout(timeoutMs: number): Promise<IteratorResult<T>> {
if (!pendingNext) {
pendingNext = iterator.next();
}
const timeout = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), timeoutMs);
});
const outcome = await Promise.race([
pendingNext.then((result) => ({ kind: "result" as const, result })),
timeout.then(() => ({ kind: "timeout" as const })),
]);
if (outcome.kind === "timeout") {
throw new Error("Timed out waiting for live event");
}
pendingNext = null;
return outcome.result;
},
};
}
describe("ClaudeAgentSession interrupt restart regression", () => { describe("ClaudeAgentSession interrupt restart regression", () => {
beforeEach(() => { beforeEach(() => {
const allowOldAssistant = deferred<void>(); const allowOldAssistant = deferred<void>();
let queryCreateCount = 0; let queryCreateCount = 0;
sdkMocks.query.mockImplementation(() => { sdkMocks.query.mockImplementation(
queryCreateCount += 1; ({ prompt }: { prompt: AsyncIterable<unknown> }) => {
if (queryCreateCount === 1) { queryCreateCount += 1;
const mock = buildFirstQueryMock(allowOldAssistant.promise); if (queryCreateCount === 1) {
sdkMocks.firstQuery = mock; const mock = buildFirstQueryMock(allowOldAssistant.promise);
sdkMocks.firstQuery = mock;
return mock;
}
const mock = buildSecondQueryMock(prompt);
sdkMocks.secondQuery = mock;
return mock; return mock;
} }
const mock = buildSecondQueryMock(); );
sdkMocks.secondQuery = mock;
return mock;
});
sdkMocks.releaseOldAssistant = () => allowOldAssistant.resolve(); sdkMocks.releaseOldAssistant = () => allowOldAssistant.resolve();
}); });
@@ -239,4 +302,382 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
await firstTurn.return?.(); await firstTurn.return?.();
await session.close(); await session.close();
}); });
test("ignores stale task-notification assistant/result events queued before the current prompt", async () => {
const logger = createTestLogger();
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const readPromptUuid = createPromptUuidReader(prompt);
let step = 0;
return {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "task-notification-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "task_notification",
task_id: "task-123",
status: "completed",
output_file: "/tmp/task-123.txt",
summary: "Codex agent is done",
session_id: "task-notification-session",
uuid: "task-note-1",
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
content: "STALE_TASK_NOTIFICATION_RESPONSE",
},
},
};
}
if (step === 3) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
if (step === 4) {
step += 1;
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
return {
done: false,
value: {
type: "user",
message: { role: "user", content: "current prompt" },
parent_tool_use_id: null,
uuid: promptUuid,
session_id: "task-notification-session",
isReplay: true,
},
};
}
if (step === 5) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
content: "CURRENT_PROMPT_RESPONSE",
},
},
};
}
if (step === 6) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
}),
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 () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
} satisfies QueryMock;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const events = await collectUntilTerminal(session.stream("current prompt"));
const assistantText = collectAssistantText(events);
expect(assistantText).toContain("CURRENT_PROMPT_RESPONSE");
expect(assistantText).not.toContain("STALE_TASK_NOTIFICATION_RESPONSE");
await session.close();
});
test("does not terminate the current prompt on a stale pre-prompt result event", async () => {
const logger = createTestLogger();
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const readPromptUuid = createPromptUuidReader(prompt);
let step = 0;
return {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "stale-result-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
if (step === 2) {
step += 1;
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
return {
done: false,
value: {
type: "user",
message: { role: "user", content: "current prompt" },
parent_tool_use_id: null,
uuid: promptUuid,
session_id: "stale-result-session",
isReplay: true,
},
};
}
if (step === 3) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
content: "FRESH_AFTER_STALE_RESULT",
},
},
};
}
if (step === 4) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
}),
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 () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
} satisfies QueryMock;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const events = await collectUntilTerminal(session.stream("current prompt"));
const assistantText = collectAssistantText(events);
expect(assistantText).toContain("FRESH_AFTER_STALE_RESULT");
await session.close();
});
test("emits autonomous live events from history tail when Claude wakes itself", async () => {
const logger = createTestLogger();
const projectDir = mkdtempSync(path.join(tmpdir(), "claude-live-tail-project-"));
const configDir = mkdtempSync(path.join(tmpdir(), "claude-live-tail-config-"));
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
sdkMocks.query.mockImplementation(() => {
let step = 0;
return {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "live-autonomous-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
}),
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 () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
} satisfies QueryMock;
});
try {
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: projectDir,
});
await collectUntilTerminal(session.stream("seed prompt"));
expect(session.describePersistence()?.sessionId).toBe(
"live-autonomous-session"
);
const sanitizedCwd = projectDir.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const historyDir = path.join(configDir, "projects", sanitizedCwd);
mkdirSync(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "live-autonomous-session.jsonl");
writeFileSync(historyPath, "", "utf8");
const liveIterator = (
session as unknown as {
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
}
).streamLiveEvents();
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
const appendAutonomousBurst = (suffix: string) => {
appendFileSync(
historyPath,
`${JSON.stringify({
type: "system",
subtype: "task_notification",
task_id: `task-${suffix}`,
status: "completed",
output_file: "/tmp/out.txt",
summary: "Background task finished",
uuid: `task-note-uuid-${suffix}`,
session_id: "live-autonomous-session",
})}\n`
);
appendFileSync(
historyPath,
`${JSON.stringify({
type: "assistant",
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
uuid: `assistant-uuid-${suffix}`,
session_id: "live-autonomous-session",
})}\n`
);
appendFileSync(
historyPath,
`${JSON.stringify({
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
uuid: `result-uuid-${suffix}`,
session_id: "live-autonomous-session",
})}\n`
);
};
const liveEvents: AgentStreamEvent[] = [];
let completed = false;
for (let attempt = 0; attempt < 4 && !completed; attempt += 1) {
appendAutonomousBurst(String(attempt));
while (liveEvents.length < 12) {
let next: IteratorResult<AgentStreamEvent>;
try {
next = await timedReader.nextWithTimeout(2_000);
} catch {
break;
}
if (next.done) {
break;
}
liveEvents.push(next.value);
if (next.value.type === "turn_completed") {
completed = true;
break;
}
}
}
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true);
expect(
liveEvents.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("AUTONOMOUS_WAKE_RESPONSE")
)
).toBe(true);
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(true);
await session.close();
} finally {
if (previousClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
}
rmSync(projectDir, { recursive: true, force: true });
rmSync(configDir, { recursive: true, force: true });
}
});
}); });
@@ -0,0 +1,351 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentTimelineRow } from "../agent-manager.js";
import { projectTimelineRows } from "../timeline-projection.js";
import { ClaudeAgentClient } from "./claude-agent.js";
const sdkMocks = vi.hoisted(() => ({
query: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: sdkMocks.query,
}));
type QueryMock = {
next: ReturnType<typeof vi.fn>;
interrupt: ReturnType<typeof vi.fn>;
return: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
};
function buildQueryMock(events: unknown[]): QueryMock {
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 () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
};
}
async function collectUntilTerminal(
stream: AsyncGenerator<AgentStreamEvent>
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of stream) {
events.push(event);
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
break;
}
}
return events;
}
function buildTailScenarioEvents(actionCount: number): unknown[] {
const actionEvents = Array.from({ length: actionCount }, (_, index) => {
const actionNumber = index + 1;
return {
type: "stream_event",
parent_tool_use_id: "task-tail-1",
event: {
type: "content_block_start",
index: actionNumber,
content_block: {
type: "tool_use",
id: `sub-read-${actionNumber}`,
name: "Read",
input: {
file_path: `file-${actionNumber}.md`,
},
},
},
};
});
return [
{
type: "system",
subtype: "init",
session_id: "sidechain-tail-session",
permissionMode: "default",
model: "opus",
},
{
type: "stream_event",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "task-tail-1",
name: "Task",
input: {
subagent_type: "Explore",
description: "Tail latest sub-agent activity",
},
},
},
},
...actionEvents,
{
type: "assistant",
parent_tool_use_id: null,
message: {
content: [
{
type: "tool_result",
tool_use_id: "task-tail-1",
tool_name: "Task",
content: "done",
is_error: false,
},
],
},
},
{
type: "result",
subtype: "success",
usage: {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
},
total_cost_usd: 0,
},
];
}
describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const logger = createTestLogger();
beforeEach(() => {
const largeOldText = "VERY_LARGE_OLD_STRING".repeat(50);
sdkMocks.query.mockImplementation(() =>
buildQueryMock([
{
type: "system",
subtype: "init",
session_id: "sidechain-session",
permissionMode: "default",
model: "opus",
},
{
type: "stream_event",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "task-call-1",
name: "Task",
input: {
subagent_type: "Explore",
description: "Inspect repository structure",
},
},
},
},
{
type: "stream_event",
parent_tool_use_id: "task-call-1",
event: {
type: "content_block_start",
index: 1,
content_block: {
type: "tool_use",
id: "sub-read-1",
name: "Read",
input: {
file_path: "README.md",
},
},
},
},
{
type: "stream_event",
parent_tool_use_id: "task-call-1",
event: {
type: "content_block_start",
index: 2,
content_block: {
type: "tool_use",
id: "sub-edit-1",
name: "Edit",
input: {
file_path: "src/index.ts",
old_string: largeOldText,
new_string: "replacement",
},
},
},
},
{
type: "tool_progress",
tool_use_id: "sub-edit-1",
tool_name: "Edit",
parent_tool_use_id: "task-call-1",
elapsed_time_seconds: 1,
},
{
type: "assistant",
parent_tool_use_id: null,
message: {
content: [
{
type: "tool_result",
tool_use_id: "task-call-1",
tool_name: "Task",
content: "done",
is_error: false,
},
],
},
},
{
type: "result",
subtype: "success",
usage: {
input_tokens: 1,
cache_read_input_tokens: 0,
output_tokens: 1,
},
total_cost_usd: 0,
},
])
);
});
afterEach(() => {
sdkMocks.query.mockReset();
});
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({ logger }).createSession({
provider: "claude",
cwd: process.cwd(),
});
const events = await collectUntilTerminal(session.stream("delegate work"));
await session.close();
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call"
)
.map((event) => event.item)
.filter((item) => item.callId === "task-call-1");
expect(timelineToolCalls.length).toBeGreaterThanOrEqual(2);
const subAgentUpdates = timelineToolCalls.filter(
(item) => item.detail.type === "sub_agent"
);
expect(subAgentUpdates.length).toBeGreaterThanOrEqual(1);
const latest = subAgentUpdates[subAgentUpdates.length - 1];
expect(latest).toBeDefined();
if (!latest || latest.detail.type !== "sub_agent") {
throw new Error("expected sub_agent detail");
}
expect(latest.detail.subAgentType).toBe("Explore");
expect(latest.detail.description).toBe("Inspect repository structure");
expect(latest.detail.actions).toEqual([
{
index: 1,
toolName: "Read",
summary: "README.md",
},
{
index: 2,
toolName: "Edit",
summary: "src/index.ts",
},
]);
expect(latest.detail.log).toContain("[Read] README.md");
expect(latest.detail.log).toContain("[Edit] src/index.ts");
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
seq: index + 1,
timestamp: `2026-02-01T00:00:0${index}.000Z`,
item,
}));
const projected = projectTimelineRows(rows, "claude", "projected");
const projectedTaskCalls = projected.filter(
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1"
);
expect(projectedTaskCalls).toHaveLength(1);
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
sdkMocks.query.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({ logger }).createSession({
provider: "claude",
cwd: process.cwd(),
});
const events = await collectUntilTerminal(session.stream("delegate work"));
await session.close();
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call"
)
.map((event) => event.item)
.filter((item) => item.callId === "task-tail-1");
const subAgentUpdates = timelineToolCalls.filter(
(item) => item.detail.type === "sub_agent"
);
const latest = subAgentUpdates[subAgentUpdates.length - 1];
expect(latest).toBeDefined();
if (!latest || latest.detail.type !== "sub_agent") {
throw new Error("expected sub_agent detail");
}
expect(latest.detail.actions).toHaveLength(200);
expect(latest.detail.actions[0]).toEqual({
index: 6,
toolName: "Read",
summary: "file-6.md",
});
expect(latest.detail.actions[199]).toEqual({
index: 205,
toolName: "Read",
summary: "file-205.md",
});
expect(latest.detail.log).not.toContain("[Read] file-1.md");
expect(latest.detail.log).not.toContain("[Read] file-5.md");
expect(latest.detail.log).toContain("[Read] file-6.md");
expect(latest.detail.log).toContain("[Read] file-205.md");
});
});
@@ -1264,7 +1264,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
); );
test( test(
"collapses sub-agent tool calls into Task metadata updates", "collapses sub-agent tool calls into Task sub_agent detail updates",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
@@ -1297,19 +1297,13 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const taskCalls = toolCalls.filter((item) => item.name === "Task"); const taskCalls = toolCalls.filter((item) => item.name === "Task");
expect(taskCalls.length).toBeGreaterThanOrEqual(1); expect(taskCalls.length).toBeGreaterThanOrEqual(1);
// Sub-agent tool calls (Read, Bash, shell, etc.) should NOT appear as // We can't 100% guarantee Claude won't also use tools directly, but Task detail
// separate timeline items — they should only appear as Task metadata updates
const subAgentLeaks = toolCalls.filter(
(item) => item.name !== "Task" && item.metadata?.subAgentActivity === undefined
);
// If there are non-Task tool calls, they must be from the main agent, not sub-agent
// We can't 100% guarantee Claude won't also use tools directly, but Task metadata
// updates should exist for the sub-agent activity // updates should exist for the sub-agent activity
const taskWithMetadata = taskCalls.filter( const taskWithSubAgentDetail = taskCalls.filter(
(item) => item.metadata?.subAgentActivity (item) => item.detail.type === "sub_agent"
); );
if (taskCalls.length > 0) { if (taskCalls.length > 0) {
expect(taskWithMetadata.length).toBeGreaterThanOrEqual(1); expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
} }
// Verify the curator produces clean output with collapsed Task entries // Verify the curator produces clean output with collapsed Task entries
@@ -29,6 +29,7 @@ import {
mapClaudeFailedToolCall, mapClaudeFailedToolCall,
mapClaudeRunningToolCall, mapClaudeRunningToolCall,
} from "./claude/tool-call-mapper.js"; } from "./claude/tool-call-mapper.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
@@ -341,12 +342,43 @@ type ToolUseCacheEntry = {
input?: AgentMetadata | null; input?: AgentMetadata | null;
}; };
type SubAgentActionEntry = {
index: number;
toolName: string;
summary?: string;
};
type SubAgentActivityState = {
subAgentType?: string;
description?: string;
actions: SubAgentActionEntry[];
actionKeys: string[];
nextActionIndex: number;
actionIndexByKey: Map<string, number>;
};
type SubAgentActionCandidate = {
key: string;
toolName: string;
input: unknown;
};
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
function isMetadata(value: unknown): value is AgentMetadata { function isMetadata(value: unknown): value is AgentMetadata {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
function readTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function isMcpServerConfig(value: unknown): value is McpServerConfig { function isMcpServerConfig(value: unknown): value is McpServerConfig {
if (!isMetadata(value)) { if (!isMetadata(value)) {
return false; return false;
@@ -684,10 +716,12 @@ class ClaudeAgentSession implements AgentSession {
private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null; private lastOptionsModel: string | null = null;
private selectableModelIds: Set<string> | null = null; private selectableModelIds: Set<string> | null = null;
private activeSidechains = new Map<string, string>(); private activeSidechains = new Map<string, SubAgentActivityState>();
private compacting = false; private compacting = false;
private queryRestartNeeded = false; private queryRestartNeeded = false;
private userMessageIds: string[] = []; private userMessageIds: string[] = [];
private sentUserMessageIds = new Set<string>();
private closed = false;
constructor( constructor(
config: ClaudeAgentConfig, config: ClaudeAgentConfig,
@@ -887,6 +921,88 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
let currentHistoryPath: string | null = null;
let lineCursor = 0;
let suppressLocalTurn = false;
let autonomousTurnActive = false;
while (!this.closed) {
const sessionId = this.claudeSessionId;
const historyPath = sessionId ? this.resolveHistoryPath(sessionId) : null;
if (!historyPath) {
await this.waitForLiveHistoryPoll();
continue;
}
let lines: string[];
try {
const content = fs.readFileSync(historyPath, "utf8");
lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
} catch {
await this.waitForLiveHistoryPoll();
continue;
}
if (currentHistoryPath !== historyPath) {
currentHistoryPath = historyPath;
lineCursor = lines.length;
suppressLocalTurn = false;
autonomousTurnActive = false;
await this.waitForLiveHistoryPoll();
continue;
}
if (lineCursor > lines.length) {
lineCursor = 0;
}
if (lineCursor === lines.length) {
await this.waitForLiveHistoryPoll();
continue;
}
const nextLines = lines.slice(lineCursor);
lineCursor = lines.length;
for (const rawLine of nextLines) {
let entry: any;
try {
entry = JSON.parse(rawLine);
} catch {
continue;
}
if (entry?.isSidechain) {
continue;
}
if (
entry?.type === "user" &&
typeof entry.uuid === "string" &&
this.sentUserMessageIds.has(entry.uuid)
) {
this.sentUserMessageIds.delete(entry.uuid);
suppressLocalTurn = true;
continue;
}
if (suppressLocalTurn) {
if (entry?.type === "result") {
suppressLocalTurn = false;
}
continue;
}
const converted = this.convertLiveHistoryEntryToEvents({
entry,
autonomousTurnActive,
});
autonomousTurnActive = converted.autonomousTurnActive;
for (const event of converted.events) {
yield event;
}
}
}
}
async getAvailableModes(): Promise<AgentMode[]> { async getAvailableModes(): Promise<AgentMode[]> {
return this.availableModes; return this.availableModes;
} }
@@ -1018,6 +1134,7 @@ class ClaudeAgentSession implements AgentSession {
} }
async close(): Promise<void> { async close(): Promise<void> {
this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed")); this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.input?.end(); this.input?.end();
await this.query?.interrupt?.(); await this.query?.interrupt?.();
@@ -1468,6 +1585,7 @@ class ClaudeAgentSession implements AgentSession {
const messageId = randomUUID(); const messageId = randomUUID();
this.rememberUserMessageId(messageId); this.rememberUserMessageId(messageId);
this.sentUserMessageIds.add(messageId);
return { return {
type: "user", type: "user",
@@ -1504,6 +1622,11 @@ class ClaudeAgentSession implements AgentSession {
} }
this.input.push(sdkMessage); this.input.push(sdkMessage);
const expectedPromptUuid =
typeof sdkMessage.uuid === "string" && sdkMessage.uuid.length > 0
? sdkMessage.uuid
: null;
let promptActivated = expectedPromptUuid === null;
while (true) { while (true) {
// Check if this turn has been superseded by a new one. // Check if this turn has been superseded by a new one.
@@ -1524,6 +1647,35 @@ class ClaudeAgentSession implements AgentSession {
break; break;
} }
// Guard against stale queued events from a prior run (for example,
// task-notification side activity): do not treat assistant/result as part
// of this prompt until we observe a prompt-owned stream start.
if (!promptActivated) {
const isTopLevelAssistantStart =
value.type === "stream_event" &&
(value as { event?: { type?: unknown }; parent_tool_use_id?: unknown }).event
?.type === "message_start" &&
((value as { parent_tool_use_id?: unknown }).parent_tool_use_id ?? null) ===
null;
if (
value.type === "user" &&
typeof value.uuid === "string" &&
value.uuid === expectedPromptUuid
) {
promptActivated = true;
continue;
}
if (isTopLevelAssistantStart) {
promptActivated = true;
} else if (value.type === "system" && value.subtype === "init") {
yield value;
continue;
}
if (!promptActivated) {
continue;
}
}
yield value; yield value;
if (value.type === "result") { if (value.type === "result") {
break; break;
@@ -1617,63 +1769,268 @@ class ClaudeAgentSession implements AgentSession {
message: SDKMessage, message: SDKMessage,
parentToolUseId: string parentToolUseId: string
): AgentStreamEvent[] { ): AgentStreamEvent[] {
let toolName: string | undefined; const state =
this.activeSidechains.get(parentToolUseId) ??
({
actions: [],
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
if (message.type === "assistant") { const contextUpdated = this.updateSubAgentContextFromTaskInput(
const content = message.message?.content; state,
if (Array.isArray(content)) { parentToolUseId
for (const block of content) { );
if (isClaudeContentChunk(block) && const actionCandidates = this.extractSubAgentActionCandidates(message);
(block.type === "tool_use" || block.type === "mcp_tool_use" || block.type === "server_tool_use") && let actionUpdated = false;
typeof block.name === "string" for (const action of actionCandidates) {
) { if (this.appendSubAgentAction(state, action)) {
toolName = block.name; actionUpdated = true;
break;
}
}
} }
} else if (message.type === "stream_event") {
const event = message.event;
if (event.type === "content_block_start") {
const cb = isClaudeContentChunk(event.content_block) ? event.content_block : null;
if (cb?.type === "tool_use" && typeof cb.name === "string") {
toolName = cb.name;
}
}
} else if (message.type === "tool_progress") {
toolName = message.tool_name;
} }
if (!toolName) { if (!contextUpdated && !actionUpdated) {
return []; return [];
} }
const prev = this.activeSidechains.get(parentToolUseId);
if (prev === toolName) {
return [];
}
this.activeSidechains.set(parentToolUseId, toolName);
const toolCall = mapClaudeRunningToolCall({ const toolCall = mapClaudeRunningToolCall({
name: "Task", name: "Task",
callId: parentToolUseId, callId: parentToolUseId,
input: null, input: null,
output: null, output: null,
metadata: { subAgentActivity: toolName },
}); });
if (!toolCall) { if (!toolCall) {
return []; return [];
} }
const detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
log: state.actions
.map((action) =>
action.summary
? `[${action.toolName}] ${action.summary}`
: `[${action.toolName}]`
)
.join("\n"),
actions: state.actions.map((action) => ({
index: action.index,
toolName: action.toolName,
...(action.summary ? { summary: action.summary } : {}),
})),
};
return [ return [
{ {
type: "timeline", type: "timeline",
item: toolCall, item: {
...toolCall,
detail,
},
provider: "claude", provider: "claude",
}, },
]; ];
} }
private updateSubAgentContextFromTaskInput(
state: SubAgentActivityState,
parentToolUseId: string
): boolean {
const taskInput = this.toolUseCache.get(parentToolUseId)?.input;
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
let changed = false;
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
state.subAgentType = nextSubAgentType;
changed = true;
}
if (nextDescription && nextDescription !== state.description) {
state.description = nextDescription;
changed = true;
}
return changed;
}
private normalizeSubAgentText(value: unknown): string | undefined {
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
}
private extractSubAgentActionCandidates(
message: SDKMessage
): SubAgentActionCandidate[] {
if (message.type === "assistant") {
const content = message.message?.content;
if (!Array.isArray(content)) {
return [];
}
const actions: SubAgentActionCandidate[] = [];
for (const block of content) {
if (
!isClaudeContentChunk(block) ||
!(
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) ||
typeof block.name !== "string"
) {
continue;
}
const key =
readTrimmedString(block.id) ??
`assistant:${block.name}:${actions.length}`;
actions.push({
key,
toolName: block.name,
input: block.input ?? null,
});
}
return actions;
}
if (message.type === "stream_event") {
const event = message.event;
if (event.type !== "content_block_start") {
return [];
}
const block = isClaudeContentChunk(event.content_block)
? event.content_block
: null;
if (
!block ||
!(
block.type === "tool_use" ||
block.type === "mcp_tool_use" ||
block.type === "server_tool_use"
) ||
typeof block.name !== "string"
) {
return [];
}
const key =
readTrimmedString(block.id) ??
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
return [
{
key,
toolName: block.name,
input: block.input ?? null,
},
];
}
if (message.type === "tool_progress") {
const toolName = readTrimmedString(message.tool_name);
if (!toolName) {
return [];
}
const key =
readTrimmedString(message.tool_use_id) ?? `progress:${toolName}`;
return [{ key, toolName, input: null }];
}
return [];
}
private appendSubAgentAction(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate
): boolean {
const normalizedToolName = readTrimmedString(candidate.toolName);
if (!normalizedToolName) {
return false;
}
const summary = this.deriveSubAgentActionSummary(
normalizedToolName,
candidate.input
);
const existingIndex = state.actionIndexByKey.get(candidate.key);
if (existingIndex !== undefined) {
const existing = state.actions[existingIndex];
if (!existing) {
return false;
}
const nextSummary = existing.summary ?? summary;
const unchanged =
existing.toolName === normalizedToolName &&
existing.summary === nextSummary;
if (unchanged) {
return false;
}
state.actions[existingIndex] = {
...existing,
toolName: normalizedToolName,
...(nextSummary ? { summary: nextSummary } : {}),
};
return true;
}
const nextEntry: SubAgentActionEntry = {
index: state.nextActionIndex,
toolName: normalizedToolName,
...(summary ? { summary } : {}),
};
state.nextActionIndex += 1;
state.actions.push(nextEntry);
state.actionKeys.push(candidate.key);
this.trimSubAgentTail(state);
this.rebuildSubAgentActionIndex(state);
return true;
}
private trimSubAgentTail(state: SubAgentActivityState): void {
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
state.actions.shift();
state.actionKeys.shift();
}
}
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
state.actionIndexByKey.clear();
for (let index = 0; index < state.actionKeys.length; index += 1) {
const key = state.actionKeys[index];
if (key) {
state.actionIndexByKey.set(key, index);
}
}
}
private deriveSubAgentActionSummary(
toolName: string,
input: unknown
): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
input,
output: null,
});
if (!runningToolCall) {
return undefined;
}
const display = buildToolCallDisplayModel({
name: runningToolCall.name,
status: runningToolCall.status,
error: runningToolCall.error,
detail: runningToolCall.detail,
metadata: runningToolCall.metadata,
});
return this.normalizeSubAgentText(display.summary);
}
private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] { private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] {
const parentToolUseId = "parent_tool_use_id" in message const parentToolUseId = "parent_tool_use_id" in message
? (message as { parent_tool_use_id: string | null }).parent_tool_use_id ? (message as { parent_tool_use_id: string | null }).parent_tool_use_id
@@ -1983,6 +2340,7 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
this.toolUseCache.clear(); this.toolUseCache.clear();
this.activeSidechains.clear();
} }
private pushToolCall( private pushToolCall(
@@ -2023,6 +2381,60 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
private waitForLiveHistoryPoll(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 250));
}
private convertLiveHistoryEntryToEvents(params: {
entry: any;
autonomousTurnActive: boolean;
}): {
events: AgentStreamEvent[];
autonomousTurnActive: boolean;
} {
const { entry, autonomousTurnActive } = params;
const events: AgentStreamEvent[] = [];
if (entry?.type === "result") {
if (!autonomousTurnActive) {
return { events, autonomousTurnActive: false };
}
const usage =
entry && typeof entry === "object" && entry.usage
? this.convertUsage(entry as SDKResultMessage)
: undefined;
if (entry?.subtype === "success") {
events.push({ type: "turn_completed", provider: "claude", usage });
} else {
const errorMessage =
Array.isArray(entry?.errors) && entry.errors.length > 0
? entry.errors.join("\n")
: "Claude run failed";
events.push({
type: "turn_failed",
provider: "claude",
error: errorMessage,
});
}
return { events, autonomousTurnActive: false };
}
let nextAutonomousActive = autonomousTurnActive;
const timelineItems = this.convertHistoryEntry(entry);
if (!nextAutonomousActive && timelineItems.length > 0) {
events.push({ type: "turn_started", provider: "claude" });
nextAutonomousActive = true;
}
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
return {
events,
autonomousTurnActive: nextAutonomousActive,
};
}
private loadPersistedHistory(sessionId: string) { private loadPersistedHistory(sessionId: string) {
try { try {
const historyPath = this.resolveHistoryPath(sessionId); const historyPath = this.resolveHistoryPath(sessionId);
@@ -2207,6 +2619,7 @@ class ClaudeAgentSession implements AgentSession {
if (typeof block.tool_use_id === "string") { if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id); this.toolUseCache.delete(block.tool_use_id);
this.activeSidechains.delete(block.tool_use_id);
} }
} }
@@ -11,7 +11,6 @@ import {
DaemonClient, DaemonClient,
} from "./test-utils/index.js"; } from "./test-utils/index.js";
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { parseServerInfoStatusPayload } from "./messages.js";
import { import {
chunkPcm16, chunkPcm16,
parsePcm16MonoWav, parsePcm16MonoWav,
@@ -280,24 +279,17 @@ describe("daemon client E2E", () => {
} }
}, 30000); }, 30000);
test("emits server_info on websocket connect", async () => { test("receives welcome on websocket connect", async () => {
const client = new DaemonClient({ const client = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
clientId: `cid-e2e-${randomUUID()}`,
clientType: "cli",
}); });
const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => {
const unsubscribe = client.on("status", (message) => {
if (message.type !== "status") return;
const payload = parseServerInfoStatusPayload(message.payload);
if (!payload) return;
resolve({ serverId: payload.serverId });
});
return unsubscribe;
});
await client.connect(); await client.connect();
const info = await infoPromise; const welcome = client.getLastWelcomeMessage();
expect(info.serverId.length).toBeGreaterThan(0); expect(welcome).not.toBeNull();
expect(welcome?.serverId.length).toBeGreaterThan(0);
expect(typeof welcome?.resumed).toBe("boolean");
await client.close(); await client.close();
}, 15000); }, 15000);
@@ -315,38 +307,20 @@ describe("daemon client E2E", () => {
const client = new DaemonClient({ const client = new DaemonClient({
url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`, url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`,
clientId: `cid-e2e-${randomUUID()}`,
clientType: "cli",
}); });
try { try {
const infoPromise = waitForSignal<{
dictationEnabled: boolean;
dictationReason: string;
voiceEnabled: boolean;
voiceReason: string;
}>(5000, (resolve) => {
const unsubscribe = client.on("status", (message) => {
if (message.type !== "status") return;
const payload = parseServerInfoStatusPayload(message.payload);
if (!payload) return;
const voice = payload.capabilities?.voice;
if (!voice) return;
resolve({
dictationEnabled: voice.dictation.enabled,
dictationReason: voice.dictation.reason,
voiceEnabled: voice.voice.enabled,
voiceReason: voice.voice.reason,
});
});
return unsubscribe;
});
await client.connect(); await client.connect();
const info = await infoPromise; const welcome = client.getLastWelcomeMessage();
const voice = welcome?.capabilities?.voice;
expect(voice).toBeTruthy();
expect(info.dictationEnabled).toBe(false); expect(voice?.dictation.enabled).toBe(false);
expect(info.dictationReason).toBe("Dictation is disabled in daemon config."); expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config.");
expect(info.voiceEnabled).toBe(false); expect(voice?.voice.enabled).toBe(false);
expect(info.voiceReason).toBe("Realtime voice is disabled in daemon config."); expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config.");
} finally { } finally {
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
await isolatedCtx.cleanup(); await isolatedCtx.cleanup();
@@ -32,15 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket {
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`; const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767"; const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`; const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
const CLIENT_SESSION_KEY = "clsk_checkout_debug"; const CLIENT_ID = "clsk_checkout_debug";
async function testMultiAgentSequence() { async function testMultiAgentSequence() {
console.log("\n=== Testing multi-agent checkout sequence ==="); console.log("\n=== Testing multi-agent checkout sequence ===");
console.log(`Daemon URL: ${DAEMON_URL}`); console.log(`Daemon URL: ${DAEMON_URL}`);
const client = new DaemonClient({ const client = new DaemonClient({
url: `${DAEMON_URL}?clientSessionKey=${CLIENT_SESSION_KEY}`, url: DAEMON_URL,
clientSessionKey: CLIENT_SESSION_KEY, clientId: CLIENT_ID,
webSocketFactory: (url) => new LoggingWebSocket(url) as any, webSocketFactory: (url) => new LoggingWebSocket(url) as any,
reconnect: { enabled: false }, reconnect: { enabled: false },
}); });
@@ -198,13 +198,12 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const offerUrl = parseOfferUrlFromLogs(lines); const offerUrl = parseOfferUrlFromLogs(lines);
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const clientId = `clt_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
const ws = new WebSocket( const ws = new WebSocket(
buildRelayWebSocketUrl({ buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`, endpoint: `127.0.0.1:${relayPort}`,
serverId, serverId,
role: "client", role: "client",
clientId,
}) })
); );
@@ -234,6 +233,8 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
ws.on("open", async () => { ws.on("open", async () => {
try { try {
let pingSent = false;
let channelRef: Awaited<ReturnType<typeof createClientChannel>> | null = null;
const channel = await createClientChannel( const channel = await createClientChannel(
transport, transport,
daemonPublicKeyB64, daemonPublicKeyB64,
@@ -242,8 +243,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
try { try {
const payload = const payload =
typeof data === "string" ? JSON.parse(data) : data; typeof data === "string" ? JSON.parse(data) : data;
// The daemon may send an initial `server_info` status message if (payload && typeof payload === "object" && (payload as any).type === "welcome") {
// immediately upon connect; ignore everything until we see `pong`. if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload && typeof payload === "object" && (payload as any).type === "pong") { if (payload && typeof payload === "object" && (payload as any).type === "pong") {
clearTimeout(timeout); clearTimeout(timeout);
resolve(payload); resolve(payload);
@@ -260,7 +266,15 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}, },
} }
); );
await channel.send(JSON.stringify({ type: "ping" })); channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (err) { } catch (err) {
clearTimeout(timeout); clearTimeout(timeout);
reject(err); reject(err);
@@ -316,9 +330,9 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
endpoint: `127.0.0.1:${relayPort}`, endpoint: `127.0.0.1:${relayPort}`,
serverId, serverId,
role: "client", role: "client",
clientId: `clt_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`,
}) })
); );
const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
const received = await new Promise<unknown>((resolve, reject) => { const received = await new Promise<unknown>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -346,9 +360,18 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
ws.on("open", async () => { ws.on("open", async () => {
try { try {
let pingSent = false;
let channelRef: Awaited<ReturnType<typeof createClientChannel>> | null = null;
const channel = await createClientChannel(transport, daemonPublicKeyB64, { const channel = await createClientChannel(transport, daemonPublicKeyB64, {
onmessage: (data) => { onmessage: (data) => {
const payload = typeof data === "string" ? JSON.parse(data) : data; const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload && typeof payload === "object" && (payload as any).type === "welcome") {
if (!pingSent && channelRef) {
pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" }));
}
return;
}
if (payload && typeof payload === "object" && (payload as any).type === "pong") { if (payload && typeof payload === "object" && (payload as any).type === "pong") {
clearTimeout(timeout); clearTimeout(timeout);
resolve(payload); resolve(payload);
@@ -360,7 +383,15 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
reject(err); reject(err);
}, },
}); });
await channel.send(JSON.stringify({ type: "ping" })); channelRef = channel;
await channel.send(
JSON.stringify({
type: "hello",
clientId: stableClientId,
clientType: "cli",
protocolVersion: 1,
})
);
} catch (err) { } catch (err) {
clearTimeout(timeout); clearTimeout(timeout);
reject(err); reject(err);
@@ -67,9 +67,19 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try { try {
await reconnected.connect(); await reconnected.connect();
await reconnected.fetchAgents({ const applySnapshot = (
subscribe: { subscriptionId: "reconnected" }, snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"]
}); ) => {
const next = applyAgentInputProcessingTransition({
snapshot,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
};
reconnected.on("agent_update", (message) => { reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") { if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
@@ -78,19 +88,24 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
if (message.payload.agent.id !== agent.id) { if (message.payload.agent.id !== agent.id) {
return; return;
} }
applySnapshot(message.payload.agent);
const next = applyAgentInputProcessingTransition({
snapshot: message.payload.agent,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
}); });
const initial = await reconnected.fetchAgents({
subscribe: { subscriptionId: "reconnected" },
});
const hydratedSnapshot = initial.entries.find(
(candidate) => candidate.agent.id === agent.id
)?.agent;
if (hydratedSnapshot) {
applySnapshot(hydratedSnapshot);
}
await secondary.waitForFinish(agent.id, 180_000); await secondary.waitForFinish(agent.id, 180_000);
const finalSnapshot = await secondary.fetchAgent(agent.id);
if (finalSnapshot) {
applySnapshot(finalSnapshot);
}
// Sending while running should clear processing even if reconnect misses the // Sending while running should clear processing even if reconnect misses the
// not-running -> running transition. // not-running -> running transition.
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
function snapshot(status: "running" | "idle", updatedAtMs: number) {
return {
status,
updatedAt: new Date(updatedAtMs).toISOString(),
} as any;
}
describe("applyAgentInputProcessingTransition", () => {
test("clears processing for stale non-running snapshot after reconnect", () => {
const result = applyAgentInputProcessingTransition({
snapshot: snapshot("idle", 1_000),
currentIsProcessing: true,
previousIsRunning: true,
latestUpdatedAt: 2_000,
});
expect(result).toEqual({
isProcessing: false,
previousIsRunning: false,
latestUpdatedAt: 2_000,
});
});
test("keeps processing for stale running snapshot", () => {
const result = applyAgentInputProcessingTransition({
snapshot: snapshot("running", 1_000),
currentIsProcessing: true,
previousIsRunning: true,
latestUpdatedAt: 2_000,
});
expect(result).toEqual({
isProcessing: true,
previousIsRunning: true,
latestUpdatedAt: 2_000,
});
});
test("clears processing when fresh update stops running", () => {
const result = applyAgentInputProcessingTransition({
snapshot: snapshot("idle", 3_000),
currentIsProcessing: true,
previousIsRunning: true,
latestUpdatedAt: 2_000,
});
expect(result).toEqual({
isProcessing: false,
previousIsRunning: false,
latestUpdatedAt: 3_000,
});
});
});
@@ -7,7 +7,18 @@ export function applyAgentInputProcessingTransition(input: {
latestUpdatedAt: number; latestUpdatedAt: number;
}): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } { }): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } {
const updatedAt = new Date(input.snapshot.updatedAt).getTime(); const updatedAt = new Date(input.snapshot.updatedAt).getTime();
const isRunning = input.snapshot.status === "running";
if (updatedAt < input.latestUpdatedAt) { if (updatedAt < input.latestUpdatedAt) {
// Reconnect flows can deliver an authoritative non-running snapshot
// whose timestamp predates this client's local "processing started" time.
// Clear processing to avoid getting stuck even when we miss an edge transition.
if (input.currentIsProcessing && !isRunning) {
return {
isProcessing: false,
previousIsRunning: false,
latestUpdatedAt: input.latestUpdatedAt,
};
}
return { return {
isProcessing: input.currentIsProcessing, isProcessing: input.currentIsProcessing,
previousIsRunning: input.previousIsRunning, previousIsRunning: input.previousIsRunning,
@@ -15,7 +26,6 @@ export function applyAgentInputProcessingTransition(input: {
}; };
} }
const isRunning = input.snapshot.status === "running";
const wasRunning = input.previousIsRunning; const wasRunning = input.previousIsRunning;
let isProcessing = input.currentIsProcessing; let isProcessing = input.currentIsProcessing;
@@ -41,7 +41,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
...getFullAccessConfig("codex"), ...getFullAccessConfig("codex"),
}); });
await primary.sendMessage(agent.id, "Run: sleep 30"); await primary.sendMessage(agent.id, "Run: sleep 5");
await primary.waitForAgentUpsert( await primary.waitForAgentUpsert(
agent.id, agent.id,
(snapshot) => snapshot.status === "running", (snapshot) => snapshot.status === "running",
@@ -64,9 +64,19 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try { try {
await reconnected.connect(); await reconnected.connect();
await reconnected.fetchAgents({ const applySnapshot = (
subscribe: { subscriptionId: "reconnected" }, snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"]
}); ) => {
const next = applyAgentInputProcessingTransition({
snapshot,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
};
reconnected.on("agent_update", (message) => { reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") { if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
@@ -75,19 +85,24 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
if (message.payload.agent.id !== agent.id) { if (message.payload.agent.id !== agent.id) {
return; return;
} }
applySnapshot(message.payload.agent);
const next = applyAgentInputProcessingTransition({
snapshot: message.payload.agent,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
}); });
const initial = await reconnected.fetchAgents({
subscribe: { subscriptionId: "reconnected" },
});
const hydratedSnapshot = initial.entries.find(
(candidate) => candidate.agent.id === agent.id
)?.agent;
if (hydratedSnapshot) {
applySnapshot(hydratedSnapshot);
}
await secondary.waitForFinish(agent.id, 120_000); await secondary.waitForFinish(agent.id, 120_000);
const finalSnapshot = await secondary.fetchAgent(agent.id);
if (finalSnapshot) {
applySnapshot(finalSnapshot);
}
// Sending while running should clear processing even if reconnect misses the // Sending while running should clear processing even if reconnect misses the
// not-running -> running transition. // not-running -> running transition.
@@ -382,17 +382,7 @@ async function runUiScenario(params: {
} }
if (action.type === "wait_for_finish") { if (action.type === "wait_for_finish") {
const startedAt = Date.now(); const result = await client.waitForFinish(agentId, action.timeoutMs);
let result = await client.waitForFinish(agentId, action.timeoutMs);
while (
result.status === "idle" &&
result.final?.status === "running" &&
Date.now() - startedAt < action.timeoutMs
) {
const elapsed = Date.now() - startedAt;
const remaining = Math.max(1_000, action.timeoutMs - elapsed);
result = await client.waitForFinish(agentId, remaining);
}
expect( expect(
result.status, result.status,
@@ -126,4 +126,64 @@ describe("waitForFinish edge cases", () => {
rmSync(cwd1, { recursive: true, force: true }); rmSync(cwd1, { recursive: true, force: true });
rmSync(cwd2, { recursive: true, force: true }); rmSync(cwd2, { recursive: true, force: true });
}, 60000); }, 60000);
test("waitForFinish keeps status and final snapshot coherent when a new run starts at idle edge", async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Idle Edge Coherence",
modeId: "bypassPermissions",
});
let sawRunning = false;
let spawnedSecondRun = false;
let secondRunDrain: Promise<void> | null = null;
const unsubscribe = ctx.daemon.daemon.agentManager.subscribe(
(event) => {
if (event.type !== "agent_state" || event.agent.id !== agent.id) {
return;
}
if (event.agent.lifecycle === "running") {
sawRunning = true;
return;
}
if (
event.agent.lifecycle !== "idle" ||
!sawRunning ||
spawnedSecondRun
) {
return;
}
spawnedSecondRun = true;
const stream = ctx.daemon.daemon.agentManager.streamAgent(
agent.id,
"Use your shell tool to run sleep 1 and then reply done."
);
secondRunDrain = (async () => {
for await (const _event of stream) {
// Drain second run so manager can settle.
}
})();
},
{ agentId: agent.id, replayState: false }
);
try {
await ctx.client.sendMessage(agent.id, "Reply with exactly: first");
const state = await ctx.client.waitForFinish(agent.id, 30000);
expect(spawnedSecondRun).toBe(true);
expect(state.status).toBe("idle");
expect(state.final?.status).toBe("idle");
expect(state.error).toBeNull();
} finally {
unsubscribe();
await secondRunDrain;
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
}); });
@@ -203,7 +203,7 @@ describe("relay-transport control lifecycle", () => {
const control = MockWebSocket.instances[0]; const control = MockWebSocket.instances[0];
control.open(); control.open();
control.message(JSON.stringify({ type: "pong", ts: Date.now() })); control.message(JSON.stringify({ type: "pong", ts: Date.now() }));
control.message(JSON.stringify({ type: "client_connected", clientId: "clt_test" })); control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }));
const dataSocket = MockWebSocket.instances[1]; const dataSocket = MockWebSocket.instances[1];
expect(dataSocket).toBeDefined(); expect(dataSocket).toBeDefined();
+30 -31
View File
@@ -32,9 +32,9 @@ type RelaySocketLike = {
}; };
type ControlMessage = type ControlMessage =
| { type: "sync"; clientIds: string[] } | { type: "sync"; connectionIds: string[] }
| { type: "client_connected"; clientId: string } | { type: "connected"; connectionId: string }
| { type: "client_disconnected"; clientId: string } | { type: "disconnected"; connectionId: string }
| { type: "ping" } | { type: "ping" }
| { type: "pong" }; | { type: "pong" };
@@ -50,15 +50,15 @@ function tryParseControlMessage(raw: unknown): ControlMessage | null {
if (!parsed || typeof parsed !== "object") return null; if (!parsed || typeof parsed !== "object") return null;
if (parsed.type === "ping") return { type: "ping" }; if (parsed.type === "ping") return { type: "ping" };
if (parsed.type === "pong") return { type: "pong" }; if (parsed.type === "pong") return { type: "pong" };
if (parsed.type === "sync" && Array.isArray(parsed.clientIds)) { if (parsed.type === "sync" && Array.isArray(parsed.connectionIds)) {
const clientIds = parsed.clientIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0); const connectionIds = parsed.connectionIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0);
return { type: "sync", clientIds }; return { type: "sync", connectionIds };
} }
if (parsed.type === "client_connected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) { if (parsed.type === "connected" && typeof parsed.connectionId === "string" && parsed.connectionId.trim()) {
return { type: "client_connected", clientId: parsed.clientId.trim() }; return { type: "connected", connectionId: parsed.connectionId.trim() };
} }
if (parsed.type === "client_disconnected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) { if (parsed.type === "disconnected" && typeof parsed.connectionId === "string" && parsed.connectionId.trim()) {
return { type: "client_disconnected", clientId: parsed.clientId.trim() }; return { type: "disconnected", connectionId: parsed.connectionId.trim() };
} }
return null; return null;
} catch { } catch {
@@ -79,7 +79,7 @@ export function startRelayTransport({
let controlWs: WebSocket | null = null; let controlWs: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null; let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0; let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws const dataSockets = new Map<string, WebSocket>(); // connectionId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null; let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null; let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0; let controlLastSeenAt = 0;
@@ -256,24 +256,24 @@ export function startRelayTransport({
} }
if (msg.type === "pong") return; if (msg.type === "pong") return;
if (msg.type === "sync") { if (msg.type === "sync") {
for (const clientId of msg.clientIds) { for (const connectionId of msg.connectionIds) {
ensureClientDataSocket(clientId); ensureClientDataSocket(connectionId);
} }
return; return;
} }
if (msg.type === "client_connected") { if (msg.type === "connected") {
ensureClientDataSocket(msg.clientId); ensureClientDataSocket(msg.connectionId);
return; return;
} }
if (msg.type === "client_disconnected") { if (msg.type === "disconnected") {
const existing = dataSockets.get(msg.clientId); const existing = dataSockets.get(msg.connectionId);
if (existing) { if (existing) {
try { try {
existing.close(1001, "Client disconnected"); existing.close(1001, "Client disconnected");
} catch { } catch {
// ignore // ignore
} }
dataSockets.delete(msg.clientId); dataSockets.delete(msg.connectionId);
} }
} }
}); });
@@ -291,25 +291,25 @@ export function startRelayTransport({
}, delayMs); }, delayMs);
}; };
const ensureClientDataSocket = (clientId: string): void => { const ensureClientDataSocket = (routingId: string): void => {
if (stopped) return; if (stopped) return;
if (!clientId) return; if (!routingId) return;
if (dataSockets.has(clientId)) return; if (dataSockets.has(routingId)) return;
const url = buildRelayWebSocketUrl({ const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint, endpoint: relayEndpoint,
serverId, serverId,
role: "server", role: "server",
clientId, connectionId: routingId,
}); });
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false }); const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
dataSockets.set(clientId, socket); dataSockets.set(routingId, socket);
let attached = false; let attached = false;
const openTimeout = setTimeout(() => { const openTimeout = setTimeout(() => {
if (stopped) return; if (stopped) return;
if (socket.readyState === WebSocket.OPEN) return; if (socket.readyState === WebSocket.OPEN) return;
relayLogger.warn({ url, clientId }, "relay_data_open_timeout_terminating"); relayLogger.warn({ url, routingId }, "relay_data_open_timeout_terminating");
try { try {
socket.terminate(); socket.terminate();
} catch { } catch {
@@ -319,18 +319,17 @@ export function startRelayTransport({
socket.on("open", () => { socket.on("open", () => {
clearTimeout(openTimeout); clearTimeout(openTimeout);
relayLogger.info({ url, clientId }, "relay_data_connected"); relayLogger.info({ url, routingId }, "relay_data_connected");
if (attached) return; if (attached) return;
attached = true; attached = true;
const externalMetadata: ExternalSocketMetadata = { const externalMetadata: ExternalSocketMetadata = {
transport: "relay", transport: "relay",
externalSessionKey: `session:${clientId}`,
}; };
if (daemonKeyPair) { if (daemonKeyPair) {
void attachEncryptedSocket( void attachEncryptedSocket(
socket, socket,
daemonKeyPair, daemonKeyPair,
relayLogger.child({ clientId }), relayLogger.child({ routingId }),
attachSocket, attachSocket,
externalMetadata externalMetadata
); );
@@ -342,16 +341,16 @@ export function startRelayTransport({
socket.on("close", (code, reason) => { socket.on("close", (code, reason) => {
clearTimeout(openTimeout); clearTimeout(openTimeout);
relayLogger.warn( relayLogger.warn(
{ code, reason: reason?.toString?.(), url, clientId }, { code, reason: reason?.toString?.(), url, routingId },
"relay_data_disconnected" "relay_data_disconnected"
); );
if (dataSockets.get(clientId) === socket) { if (dataSockets.get(routingId) === socket) {
dataSockets.delete(clientId); dataSockets.delete(routingId);
} }
}); });
socket.on("error", (err) => { socket.on("error", (err) => {
relayLogger.warn({ err, url, clientId }, "relay_data_error"); relayLogger.warn({ err, url, routingId }, "relay_data_error");
}); });
}; };
+17 -4
View File
@@ -5421,16 +5421,29 @@ export class Session {
}, effectiveTimeoutMs) }, effectiveTimeoutMs)
try { try {
const result = await this.agentManager.waitForAgentEvent(agentId, { let result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal, signal: abortController.signal,
}) })
let final = await this.getAgentPayloadById(agentId)
const final = await this.getAgentPayloadById(agentId)
if (!final) { if (!final) {
throw new Error(`Agent ${agentId} disappeared while waiting`) throw new Error(`Agent ${agentId} disappeared while waiting`)
} }
const status = result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle' let status: 'permission' | 'error' | 'idle' =
result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
// Keep wait_for_finish coherent: never return idle while the final snapshot is running.
// This can happen if a new run starts between wait resolution and final snapshot fetch.
while (status === 'idle' && final.status === 'running') {
result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal,
})
final = await this.getAgentPayloadById(agentId)
if (!final) {
throw new Error(`Agent ${agentId} disappeared while waiting`)
}
status = result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
}
this.emit({ this.emit({
type: 'wait_for_finish_response', type: 'wait_for_finish_response',
@@ -11,24 +11,24 @@ import {
export type DaemonClientConfig = Omit< export type DaemonClientConfig = Omit<
SharedDaemonClientConfig, SharedDaemonClientConfig,
"webSocketFactory" | "transportFactory" "webSocketFactory" | "transportFactory" | "clientId"
>; >;
export type CreateAgentOptions = CreateAgentRequestOptions; export type CreateAgentOptions = CreateAgentRequestOptions;
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler }; export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler };
let testClientCounter = 0; let testClientCounter = 0;
function nextTestClientSessionKey(): string { function nextTestClientId(): string {
testClientCounter += 1; testClientCounter += 1;
return `clsk_test_client_${testClientCounter}`; return `clid_test_client_${testClientCounter}`;
} }
export class DaemonClient extends SharedDaemonClient { export class DaemonClient extends SharedDaemonClient {
constructor(config: DaemonClientConfig) { constructor(config: DaemonClientConfig) {
const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey(); const clientId = nextTestClientId();
super({ super({
...config, ...config,
clientSessionKey, clientId,
webSocketFactory: (url, options) => webSocketFactory: (url, options) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike, new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
}); });
@@ -74,7 +74,6 @@ vi.mock("./push/push-service.js", () => ({
import { import {
VoiceAssistantWebSocketServer, VoiceAssistantWebSocketServer,
type ExternalSocketMetadata,
} from "./websocket-server"; } from "./websocket-server";
import { parseServerInfoStatusPayload } from "./messages.js"; import { parseServerInfoStatusPayload } from "./messages.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
@@ -242,6 +241,65 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
}; };
} }
function createHelloMessage(clientId: string) {
return {
type: "hello" as const,
clientId,
clientType: "cli" as const,
protocolVersion: 1,
};
}
function createDirectRequest() {
return {
headers: {
host: "localhost:6767",
origin: "http://localhost:6767",
"user-agent": "vitest",
},
socket: {
remoteAddress: "127.0.0.1",
},
url: "/ws",
};
}
async function attachRelayAndHello(params: {
server: VoiceAssistantWebSocketServer;
socket: MockSocket;
clientId: string;
}) {
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
await Promise.resolve();
expect(params.socket.sent.length).toBeGreaterThan(0);
const welcome = JSON.parse(params.socket.sent[0] as string) as {
type?: unknown;
resumed?: unknown;
serverId?: unknown;
};
expect(welcome.type).toBe("welcome");
return welcome;
}
async function attachDirectAndHello(params: {
server: VoiceAssistantWebSocketServer;
socket: MockSocket;
clientId: string;
}) {
await (params.server as any).attachSocket(params.socket, createDirectRequest());
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
await Promise.resolve();
expect(params.socket.sent.length).toBeGreaterThan(0);
const welcome = JSON.parse(params.socket.sent[0] as string) as {
type?: unknown;
resumed?: unknown;
serverId?: unknown;
};
expect(welcome.type).toBe("welcome");
return welcome;
}
describe("relay external socket reconnect behavior", () => { describe("relay external socket reconnect behavior", () => {
beforeEach(() => { beforeEach(() => {
sessionMock.instances.length = 0; sessionMock.instances.length = 0;
@@ -254,13 +312,15 @@ describe("relay external socket reconnect behavior", () => {
test("keeps the same session when relay reconnects within grace window", async () => { test("keeps the same session when relay reconnects within grace window", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const clientId = "cid-relay-reconnect";
transport: "relay",
externalSessionKey: "session:client-1",
};
const socket1 = new MockSocket(); const socket1 = new MockSocket();
await server.attachExternalSocket(socket1, metadata); const firstWelcome = await attachRelayAndHello({
server,
socket: socket1,
clientId,
});
expect(firstWelcome.resumed).toBe(false);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
@@ -269,7 +329,12 @@ describe("relay external socket reconnect behavior", () => {
expect(session.cleanup).not.toHaveBeenCalled(); expect(session.cleanup).not.toHaveBeenCalled();
const socket2 = new MockSocket(); const socket2 = new MockSocket();
await server.attachExternalSocket(socket2, metadata); const secondWelcome = await attachRelayAndHello({
server,
socket: socket2,
clientId,
});
expect(secondWelcome.resumed).toBe(true);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(20_000); await vi.advanceTimersByTimeAsync(20_000);
@@ -278,19 +343,8 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); await server.close();
}); });
test("rejects direct socket attach when clientSessionKey is missing", async () => { test("closes pending connection when hello timeout elapses", async () => {
const server = createServer(); const server = createServer();
const request = {
headers: {
host: "localhost:6767",
origin: "http://localhost:6767",
"user-agent": "vitest",
},
socket: {
remoteAddress: "127.0.0.1",
},
url: "/ws",
};
const socket = new MockSocket(); const socket = new MockSocket();
let closeCode: number | null = null; let closeCode: number | null = null;
@@ -300,63 +354,105 @@ describe("relay external socket reconnect behavior", () => {
closeReason = typeof reason === "string" ? reason : String(reason ?? ""); closeReason = typeof reason === "string" ? reason : String(reason ?? "");
}); });
await (server as any).attachSocket(socket, request); await (server as any).attachSocket(socket, createDirectRequest());
await vi.advanceTimersByTimeAsync(15_000);
expect(closeCode).toBe(1008); expect(closeCode).toBe(4001);
expect(closeReason).toBe("Missing clientSessionKey"); expect(closeReason).toBe("Hello timeout");
expect(sessionMock.instances).toHaveLength(0); expect(sessionMock.instances).toHaveLength(0);
await server.close(); await server.close();
}); });
test("attaches multiple sockets to the same relay external session key", async () => { test("marks hello as resumed when clientId already has a session", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const clientId = "cid-resume-flag";
transport: "relay",
externalSessionKey: "session:client-multi",
};
const socket1 = new MockSocket(); const firstSocket = new MockSocket();
await server.attachExternalSocket(socket1, metadata); const firstWelcome = await attachRelayAndHello({
expect(sessionMock.instances).toHaveLength(1); server,
const session = sessionMock.instances[0]!; socket: firstSocket,
clientId,
const socket2 = new MockSocket();
await server.attachExternalSocket(socket2, metadata);
expect(sessionMock.instances).toHaveLength(1);
const onMessage = session.args.onMessage as
| ((msg: { type: "status"; payload: { status: string } }) => void)
| undefined;
expect(onMessage).toBeTypeOf("function");
onMessage?.({
type: "status",
payload: { status: "ok" },
}); });
expect(firstWelcome.resumed).toBe(false);
expect(socket1.sent.length).toBeGreaterThan(1); firstSocket.emit("close", 1006, "");
expect(socket2.sent.length).toBeGreaterThan(1); await vi.advanceTimersByTimeAsync(1_000);
const secondSocket = new MockSocket();
const secondWelcome = await attachRelayAndHello({
server,
socket: secondSocket,
clientId,
});
expect(secondWelcome.resumed).toBe(true);
await server.close(); await server.close();
}); });
test("reuses direct session when clientSessionKey reconnects within grace window", async () => { test("marks hello as not resumed for new clientIds", async () => {
const server = createServer(); const server = createServer();
const request = {
headers: { const firstSocket = new MockSocket();
host: "localhost:6767", const firstWelcome = await attachRelayAndHello({
origin: "http://localhost:6767", server,
"user-agent": "vitest", socket: firstSocket,
}, clientId: "cid-new-1",
socket: { });
remoteAddress: "127.0.0.1", expect(firstWelcome.resumed).toBe(false);
},
url: "/ws?clientSessionKey=clsk_direct_reconnect", const secondSocket = new MockSocket();
}; const secondWelcome = await attachRelayAndHello({
server,
socket: secondSocket,
clientId: "cid-new-2",
});
expect(secondWelcome.resumed).toBe(false);
expect(sessionMock.instances).toHaveLength(2);
await server.close();
});
test("rejects session messages before hello", async () => {
const server = createServer();
const socket = new MockSocket();
let closeCode: number | null = null;
let closeReason = "";
socket.on("close", (code: unknown, reason: unknown) => {
closeCode = typeof code === "number" ? code : null;
closeReason = typeof reason === "string" ? reason : String(reason ?? "");
});
await server.attachExternalSocket(socket, { transport: "relay" });
socket.emit(
"message",
JSON.stringify({
type: "session",
message: {
type: "ping",
},
})
);
await Promise.resolve();
expect(closeCode).toBe(4002);
expect(["Invalid hello", "Session message before hello"]).toContain(closeReason);
expect(sessionMock.instances).toHaveLength(0);
await server.close();
});
test("reuses direct session when same clientId reconnects within grace window", async () => {
const server = createServer();
const clientId = "cid-direct-reconnect";
const socket1 = new MockSocket(); const socket1 = new MockSocket();
await (server as any).attachSocket(socket1, request); const firstWelcome = await attachDirectAndHello({
server,
socket: socket1,
clientId,
});
expect(firstWelcome.resumed).toBe(false);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
@@ -365,7 +461,12 @@ describe("relay external socket reconnect behavior", () => {
expect(session.cleanup).not.toHaveBeenCalled(); expect(session.cleanup).not.toHaveBeenCalled();
const socket2 = new MockSocket(); const socket2 = new MockSocket();
await (server as any).attachSocket(socket2, request); const secondWelcome = await attachDirectAndHello({
server,
socket: socket2,
clientId,
});
expect(secondWelcome.resumed).toBe(true);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(20_000); await vi.advanceTimersByTimeAsync(20_000);
@@ -374,32 +475,25 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); await server.close();
}); });
test("reuses one session when switching from direct to relay with the same session key", async () => { test("reuses one session when switching from direct to relay with the same clientId", async () => {
const server = createServer(); const server = createServer();
const clientSessionKey = "clsk_switch_path"; const clientId = "cid-switch-path";
const directRequest = {
headers: {
host: "localhost:6767",
origin: "http://localhost:6767",
"user-agent": "vitest",
},
socket: {
remoteAddress: "127.0.0.1",
},
url: `/ws?clientSessionKey=${clientSessionKey}`,
};
const relayMetadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: `session:${clientSessionKey}`,
};
const directSocket = new MockSocket(); const directSocket = new MockSocket();
await (server as any).attachSocket(directSocket, directRequest); await attachDirectAndHello({
server,
socket: directSocket,
clientId,
});
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
const relaySocket = new MockSocket(); const relaySocket = new MockSocket();
await server.attachExternalSocket(relaySocket, relayMetadata); await attachRelayAndHello({
server,
socket: relaySocket,
clientId,
});
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const onMessage = session.args.onMessage as const onMessage = session.args.onMessage as
@@ -411,8 +505,8 @@ describe("relay external socket reconnect behavior", () => {
payload: { status: "ok" }, payload: { status: "ok" },
}); });
expect(directSocket.sent.length).toBeGreaterThan(1); expect(directSocket.sent.length).toBeGreaterThan(0);
expect(relaySocket.sent.length).toBeGreaterThan(1); expect(relaySocket.sent.length).toBeGreaterThan(0);
directSocket.emit("close", 1006, ""); directSocket.emit("close", 1006, "");
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
@@ -427,13 +521,14 @@ describe("relay external socket reconnect behavior", () => {
test("cleans up relay session when reconnect grace expires", async () => { test("cleans up relay session when reconnect grace expires", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = { const clientId = "cid-relay-grace-expire";
transport: "relay",
externalSessionKey: "session:client-2",
};
const socket1 = new MockSocket(); const socket1 = new MockSocket();
await server.attachExternalSocket(socket1, metadata); await attachRelayAndHello({
server,
socket: socket1,
clientId,
});
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
@@ -444,53 +539,46 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); await server.close();
}); });
test("includes voice capabilities in server_info when speech readiness exists", async () => { test("includes voice capabilities in welcome when speech readiness exists", async () => {
const speechReadiness = createReadySpeechReadinessSnapshot(); const speechReadiness = createReadySpeechReadinessSnapshot();
const server = createServer({ speechReadiness }); const server = createServer({ speechReadiness });
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "session:client-server-info-capabilities",
};
const socket = new MockSocket(); const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata); const welcome = await attachRelayAndHello({
server,
expect(socket.sent).toHaveLength(1); socket,
expect(typeof socket.sent[0]).toBe("string"); clientId: "cid-server-info-capabilities",
const envelope = JSON.parse(socket.sent[0] as string) as { }) as {
type?: unknown; version?: unknown;
message?: { capabilities?: {
type?: unknown; voice?: {
payload?: unknown; dictation?: { enabled?: unknown; reason?: unknown };
voice?: { enabled?: unknown; reason?: unknown };
};
}; };
}; };
expect(envelope.type).toBe("session"); expect(welcome.version).toBe(TEST_DAEMON_VERSION);
expect(envelope.message?.type).toBe("status"); expect(welcome.capabilities?.voice?.dictation?.enabled).toBe(
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
expect(payload?.status).toBe("server_info");
expect(payload?.version).toBe(TEST_DAEMON_VERSION);
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(
speechReadiness.dictation.enabled speechReadiness.dictation.enabled
); );
expect(payload?.capabilities?.voice?.dictation.reason).toBe(""); expect(welcome.capabilities?.voice?.dictation?.reason).toBe("");
expect(payload?.capabilities?.voice?.voice.enabled).toBe( expect(welcome.capabilities?.voice?.voice?.enabled).toBe(
speechReadiness.realtimeVoice.enabled speechReadiness.realtimeVoice.enabled
); );
expect(payload?.capabilities?.voice?.voice.reason).toBe(""); expect(welcome.capabilities?.voice?.voice?.reason).toBe("");
await server.close(); await server.close();
}); });
test("broadcasts updated server_info when capabilities change", async () => { test("broadcasts updated server_info when capabilities change", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "session:client-server-info-broadcast",
};
const socket = new MockSocket(); const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata); await attachRelayAndHello({
server,
socket,
clientId: "cid-server-info-broadcast",
});
expect(socket.sent).toHaveLength(1); expect(socket.sent).toHaveLength(1);
const speechReadiness = createReadySpeechReadinessSnapshot(); const speechReadiness = createReadySpeechReadinessSnapshot();
@@ -513,12 +601,12 @@ describe("relay external socket reconnect behavior", () => {
test("includes temporary retry guidance while models are downloading", async () => { test("includes temporary retry guidance while models are downloading", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "session:client-server-info-download-guidance",
};
const socket = new MockSocket(); const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata); await attachRelayAndHello({
server,
socket,
clientId: "cid-server-info-download-guidance",
});
expect(socket.sent).toHaveLength(1); expect(socket.sent).toHaveLength(1);
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot()); server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
@@ -542,13 +630,13 @@ describe("relay external socket reconnect behavior", () => {
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => { test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "session:client-binary-inbound",
};
const socket = new MockSocket(); const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata); await attachRelayAndHello({
server,
socket,
clientId: "cid-binary-inbound",
});
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
@@ -583,13 +671,13 @@ describe("relay external socket reconnect behavior", () => {
test("sends outbound binary mux frames from session over websocket", async () => { test("sends outbound binary mux frames from session over websocket", async () => {
const server = createServer(); const server = createServer();
const metadata: ExternalSocketMetadata = {
transport: "relay",
externalSessionKey: "session:client-binary-outbound",
};
const socket = new MockSocket(); const socket = new MockSocket();
await server.attachExternalSocket(socket, metadata); await attachRelayAndHello({
server,
socket,
clientId: "cid-binary-outbound",
});
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
+277 -121
View File
@@ -10,6 +10,8 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino"; import type pino from "pino";
import { import {
type ServerInfoStatusPayload, type ServerInfoStatusPayload,
type WSHelloMessage,
type WSWelcomeMessage,
WSInboundMessageSchema, WSInboundMessageSchema,
type ServerCapabilityState, type ServerCapabilityState,
type ServerCapabilities, type ServerCapabilities,
@@ -51,7 +53,11 @@ import {
export type AgentMcpTransportFactory = () => Promise<Transport>; export type AgentMcpTransportFactory = () => Promise<Transport>;
export type ExternalSocketMetadata = { export type ExternalSocketMetadata = {
transport: "relay"; transport: "relay";
externalSessionKey: string; };
type PendingConnection = {
connectionLogger: pino.Logger;
helloTimeout: ReturnType<typeof setTimeout> | null;
}; };
type WebSocketServerConfig = { type WebSocketServerConfig = {
@@ -152,11 +158,15 @@ type SessionConnection = {
clientId: string; clientId: string;
connectionLogger: pino.Logger; connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>; sockets: Set<WebSocketLike>;
externalSessionKey: string | null;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null; externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
}; };
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
const HELLO_TIMEOUT_MS = 15_000;
const WS_CLOSE_HELLO_TIMEOUT = 4001;
const WS_CLOSE_INVALID_HELLO = 4002;
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_PROTOCOL_VERSION = 1;
export class MissingDaemonVersionError extends Error { export class MissingDaemonVersionError extends Error {
constructor() { constructor() {
@@ -171,9 +181,9 @@ export class MissingDaemonVersionError extends Error {
export class VoiceAssistantWebSocketServer { export class VoiceAssistantWebSocketServer {
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
private readonly wss: WebSocketServer; private readonly wss: WebSocketServer;
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map(); private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map(); private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
private clientIdCounter = 0;
private readonly serverId: string; private readonly serverId: string;
private readonly daemonVersion: string; private readonly daemonVersion: string;
private readonly agentManager: AgentManager; private readonly agentManager: AgentManager;
@@ -334,7 +344,7 @@ export class VoiceAssistantWebSocketServer {
return; return;
} }
this.serverCapabilities = next; this.serverCapabilities = next;
this.broadcastServerInfo(); this.broadcastCapabilitiesUpdate();
} }
public async attachExternalSocket( public async attachExternalSocket(
@@ -350,6 +360,14 @@ export class VoiceAssistantWebSocketServer {
...this.externalSessionsByKey.values(), ...this.externalSessionsByKey.values(),
]); ]);
const pendingSockets = new Set<WebSocketLike>(this.pendingConnections.keys());
for (const pending of this.pendingConnections.values()) {
if (pending.helloTimeout) {
clearTimeout(pending.helloTimeout);
pending.helloTimeout = null;
}
}
const cleanupPromises: Promise<void>[] = []; const cleanupPromises: Promise<void>[] = [];
for (const connection of uniqueConnections) { for (const connection of uniqueConnections) {
if (connection.externalDisconnectCleanupTimeout) { if (connection.externalDisconnectCleanupTimeout) {
@@ -372,7 +390,22 @@ export class VoiceAssistantWebSocketServer {
); );
} }
} }
for (const ws of pendingSockets) {
cleanupPromises.push(
new Promise<void>((resolve) => {
if (ws.readyState === 3) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
})
);
}
await Promise.all(cleanupPromises); await Promise.all(cleanupPromises);
this.pendingConnections.clear();
this.sessions.clear(); this.sessions.clear();
this.externalSessionsByKey.clear(); this.externalSessionsByKey.clear();
this.wss.close(); this.wss.close();
@@ -416,61 +449,7 @@ export class VoiceAssistantWebSocketServer {
metadata?: ExternalSocketMetadata metadata?: ExternalSocketMetadata
): Promise<void> { ): Promise<void> {
const requestMetadata = extractSocketRequestMetadata(request); const requestMetadata = extractSocketRequestMetadata(request);
const relayExternalSessionKey =
metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0
? metadata.externalSessionKey
: null;
const directExternalSessionKey =
typeof requestMetadata.clientSessionKey === "string" &&
requestMetadata.clientSessionKey.trim().length > 0
? `session:${requestMetadata.clientSessionKey.trim()}`
: null;
const externalSessionKey = relayExternalSessionKey ?? directExternalSessionKey;
if (metadata?.transport !== "relay" && !directExternalSessionKey) {
this.logger.warn(
{
host: requestMetadata.host,
origin: requestMetadata.origin,
remoteAddress: requestMetadata.remoteAddress,
},
"Rejected direct connection without clientSessionKey"
);
try {
ws.close(1008, "Missing clientSessionKey");
} catch {
// ignore close errors
}
return;
}
if (externalSessionKey) {
const existing = this.externalSessionsByKey.get(externalSessionKey);
if (existing) {
if (existing.externalDisconnectCleanupTimeout) {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
this.sendServerInfo(ws);
existing.connectionLogger.trace(
{
clientId: existing.clientId,
externalSessionKey,
totalSessions: this.sessions.size,
},
"Client reconnected"
);
this.bindSocketHandlers(ws, existing);
return;
}
}
const clientId = `client-${++this.clientIdCounter}`;
const connectionLoggerFields: Record<string, string> = { const connectionLoggerFields: Record<string, string> = {
clientId,
transport: metadata?.transport === "relay" ? "relay" : "direct", transport: metadata?.transport === "relay" ? "relay" : "direct",
}; };
if (requestMetadata.host) { if (requestMetadata.host) {
@@ -486,6 +465,47 @@ export class VoiceAssistantWebSocketServer {
connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress; connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
} }
const connectionLogger = this.logger.child(connectionLoggerFields); const connectionLogger = this.logger.child(connectionLoggerFields);
const pending: PendingConnection = {
connectionLogger,
helloTimeout: null,
};
const timeout = setTimeout(() => {
if (this.pendingConnections.get(ws) !== pending) {
return;
}
pending.helloTimeout = null;
this.pendingConnections.delete(ws);
pending.connectionLogger.warn(
{ timeoutMs: HELLO_TIMEOUT_MS },
"Closing connection due to missing hello"
);
try {
ws.close(WS_CLOSE_HELLO_TIMEOUT, "Hello timeout");
} catch {
// ignore close errors
}
}, HELLO_TIMEOUT_MS);
pending.helloTimeout = timeout;
(timeout as unknown as { unref?: () => void }).unref?.();
this.pendingConnections.set(ws, pending);
this.bindSocketHandlers(ws);
pending.connectionLogger.trace(
{
totalPendingConnections: this.pendingConnections.size,
},
"Client connected; awaiting hello"
);
}
private createSessionConnection(params: {
ws: WebSocketLike;
clientId: string;
connectionLogger: pino.Logger;
}): SessionConnection {
const { ws, clientId, connectionLogger } = params;
let connection: SessionConnection | null = null; let connection: SessionConnection | null = null;
const session = new Session({ const session = new Session({
@@ -538,23 +558,109 @@ export class VoiceAssistantWebSocketServer {
clientId, clientId,
connectionLogger, connectionLogger,
sockets: new Set([ws]), sockets: new Set([ws]),
externalSessionKey,
externalDisconnectCleanupTimeout: null, externalDisconnectCleanupTimeout: null,
}; };
return connection;
}
this.sessions.set(ws, connection); private clearPendingConnection(ws: WebSocketLike): PendingConnection | null {
if (externalSessionKey) { const pending = this.pendingConnections.get(ws);
this.externalSessionsByKey.set(externalSessionKey, connection); if (!pending) {
return null;
}
if (pending.helloTimeout) {
clearTimeout(pending.helloTimeout);
pending.helloTimeout = null;
}
this.pendingConnections.delete(ws);
return pending;
}
private buildWelcomeMessage(params: { resumed: boolean }): WSWelcomeMessage {
return {
type: "welcome",
serverId: this.serverId,
hostname: getHostname(),
version: this.daemonVersion,
resumed: params.resumed,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
};
}
private handleHello(params: {
ws: WebSocketLike;
message: WSHelloMessage;
pending: PendingConnection;
}): void {
const { ws, message, pending } = params;
if (message.protocolVersion !== WS_PROTOCOL_VERSION) {
this.clearPendingConnection(ws);
pending.connectionLogger.warn(
{
receivedProtocolVersion: message.protocolVersion,
expectedProtocolVersion: WS_PROTOCOL_VERSION,
},
"Rejected hello due to protocol version mismatch"
);
try {
ws.close(WS_CLOSE_INCOMPATIBLE_PROTOCOL, "Incompatible protocol version");
} catch {
// ignore close errors
}
return;
} }
this.sendServerInfo(ws); const clientId = message.clientId.trim();
if (clientId.length === 0) {
this.clearPendingConnection(ws);
pending.connectionLogger.warn("Rejected hello with empty clientId");
try {
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
} catch {
// ignore close errors
}
return;
}
connectionLogger.trace( this.clearPendingConnection(ws);
{ clientId, externalSessionKey, totalSessions: this.sessions.size }, const existing = this.externalSessionsByKey.get(clientId);
"Client connected" if (existing) {
if (existing.externalDisconnectCleanupTimeout) {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
this.sendToClient(ws, this.buildWelcomeMessage({ resumed: true }));
existing.connectionLogger.trace(
{
clientId,
resumed: true,
totalSessions: this.sessions.size,
},
"Client connected via hello"
);
return;
}
const connectionLogger = pending.connectionLogger.child({ clientId });
const connection = this.createSessionConnection({
ws,
clientId,
connectionLogger,
});
this.sessions.set(ws, connection);
this.externalSessionsByKey.set(clientId, connection);
this.sendToClient(ws, this.buildWelcomeMessage({ resumed: false }));
connection.connectionLogger.trace(
{
clientId,
resumed: false,
totalSessions: this.sessions.size,
},
"Client connected via hello"
); );
this.bindSocketHandlers(ws, connection);
} }
private buildServerInfoStatusPayload(): ServerInfoStatusPayload { private buildServerInfoStatusPayload(): ServerInfoStatusPayload {
@@ -567,7 +673,7 @@ export class VoiceAssistantWebSocketServer {
}; };
} }
private broadcastServerInfo(): void { private broadcastCapabilitiesUpdate(): void {
this.broadcast( this.broadcast(
wrapSessionMessage({ wrapSessionMessage({
type: "status", type: "status",
@@ -576,27 +682,13 @@ export class VoiceAssistantWebSocketServer {
); );
} }
private sendServerInfo(ws: WebSocketLike): void { private bindSocketHandlers(ws: WebSocketLike): void {
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: this.buildServerInfoStatusPayload(),
})
);
}
private bindSocketHandlers(
ws: WebSocketLike,
connection: SessionConnection
): void {
ws.on("message", (data) => { ws.on("message", (data) => {
void this.handleRawMessage(ws, data); void this.handleRawMessage(ws, data);
}); });
ws.on("close", async (code: number, reason: unknown) => { ws.on("close", async (code: number, reason: unknown) => {
await this.detachSocket(ws, connection, { await this.detachSocket(ws, {
code: typeof code === "number" ? code : undefined, code: typeof code === "number" ? code : undefined,
reason, reason,
}); });
@@ -604,8 +696,11 @@ export class VoiceAssistantWebSocketServer {
ws.on("error", async (error) => { ws.on("error", async (error) => {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
connection.connectionLogger.error({ err }, "Client error"); const active = this.sessions.get(ws);
await this.detachSocket(ws, connection, { error: err }); const pending = this.pendingConnections.get(ws);
const log = active?.connectionLogger ?? pending?.connectionLogger ?? this.logger;
log.error({ err }, "Client error");
await this.detachSocket(ws, { error: err });
}); });
} }
@@ -623,19 +718,33 @@ export class VoiceAssistantWebSocketServer {
private async detachSocket( private async detachSocket(
ws: WebSocketLike, ws: WebSocketLike,
connection: SessionConnection,
details: { details: {
code?: number; code?: number;
reason?: unknown; reason?: unknown;
error?: Error; error?: Error;
} }
): Promise<void> { ): Promise<void> {
const activeConnection = this.sessions.get(ws); const pending = this.clearPendingConnection(ws);
if (activeConnection !== connection) return; if (pending) {
pending.connectionLogger.trace(
{
code: details.code,
reason: stringifyCloseReason(details.reason),
},
"Pending client disconnected"
);
return;
}
const connection = this.sessions.get(ws);
if (!connection) {
return;
}
this.sessions.delete(ws); this.sessions.delete(ws);
connection.sockets.delete(ws); connection.sockets.delete(ws);
if (connection.externalSessionKey && connection.sockets.size === 0) { if (connection.sockets.size === 0) {
if (connection.externalDisconnectCleanupTimeout) { if (connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout); clearTimeout(connection.externalDisconnectCleanupTimeout);
} }
@@ -651,7 +760,6 @@ export class VoiceAssistantWebSocketServer {
connection.connectionLogger.trace( connection.connectionLogger.trace(
{ {
clientId: connection.clientId, clientId: connection.clientId,
externalSessionKey: connection.externalSessionKey,
code: details.code, code: details.code,
reason: stringifyCloseReason(details.reason), reason: stringifyCloseReason(details.reason),
reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS, reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS,
@@ -690,11 +798,9 @@ export class VoiceAssistantWebSocketServer {
this.sessions.delete(socket); this.sessions.delete(socket);
} }
connection.sockets.clear(); connection.sockets.clear();
if (connection.externalSessionKey) { const existing = this.externalSessionsByKey.get(connection.clientId);
const existing = this.externalSessionsByKey.get(connection.externalSessionKey); if (existing === connection) {
if (existing === connection) { this.externalSessionsByKey.delete(connection.clientId);
this.externalSessionsByKey.delete(connection.externalSessionKey);
}
} }
connection.connectionLogger.trace( connection.connectionLogger.trace(
@@ -708,15 +814,24 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike, ws: WebSocketLike,
data: Buffer | ArrayBuffer | Buffer[] | string data: Buffer | ArrayBuffer | Buffer[] | string
): Promise<void> { ): Promise<void> {
const activeConnection = this.sessions.get(ws);
const pendingConnection = this.pendingConnections.get(ws);
const log = activeConnection?.connectionLogger ?? pendingConnection?.connectionLogger ?? this.logger;
try { try {
const activeConnection = this.sessions.get(ws);
const buffer = bufferFromWsData(data); const buffer = bufferFromWsData(data);
const asBytes = asUint8Array(buffer); const asBytes = asUint8Array(buffer);
if (asBytes) { if (asBytes) {
const frame = decodeBinaryMuxFrame(asBytes); const frame = decodeBinaryMuxFrame(asBytes);
if (frame) { if (frame) {
if (!activeConnection) { if (!activeConnection) {
this.logger.error("No session found for client"); log.warn("Rejected binary frame before hello");
this.clearPendingConnection(ws);
try {
ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
} catch {
// ignore close errors
}
return; return;
} }
activeConnection.session.handleBinaryFrame(frame); activeConnection.session.handleBinaryFrame(frame);
@@ -726,6 +841,22 @@ export class VoiceAssistantWebSocketServer {
const parsed = JSON.parse(buffer.toString()); const parsed = JSON.parse(buffer.toString());
const parsedMessage = WSInboundMessageSchema.safeParse(parsed); const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
if (!parsedMessage.success) { if (!parsedMessage.success) {
if (pendingConnection) {
pendingConnection.connectionLogger.warn(
{
error: parsedMessage.error.message,
},
"Rejected pending message before hello"
);
this.clearPendingConnection(ws);
try {
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
} catch {
// ignore close errors
}
return;
}
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed); const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
const isUnknownSchema = const isUnknownSchema =
requestInfo?.requestId != null && requestInfo?.requestId != null &&
@@ -734,7 +865,6 @@ export class VoiceAssistantWebSocketServer {
"type" in parsed && "type" in parsed &&
(parsed as { type?: unknown }).type === "session"; (parsed as { type?: unknown }).type === "session";
const log = activeConnection?.connectionLogger ?? this.logger;
log.warn( log.warn(
{ {
clientId: activeConnection?.clientId, clientId: activeConnection?.clientId,
@@ -786,8 +916,43 @@ export class VoiceAssistantWebSocketServer {
return; return;
} }
if (pendingConnection) {
if (message.type === "hello") {
this.handleHello({
ws,
message,
pending: pendingConnection,
});
return;
}
pendingConnection.connectionLogger.warn(
{
messageType: message.type,
},
"Rejected pending message before hello"
);
this.clearPendingConnection(ws);
try {
ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
} catch {
// ignore close errors
}
return;
}
if (!activeConnection) { if (!activeConnection) {
this.logger.error("No session found for client"); this.logger.error("No connection found for websocket");
return;
}
if (message.type === "hello") {
activeConnection.connectionLogger.warn("Received hello on active connection");
try {
ws.close(WS_CLOSE_INVALID_HELLO, "Unexpected hello");
} catch {
// ignore close errors
}
return; return;
} }
@@ -816,7 +981,7 @@ export class VoiceAssistantWebSocketServer {
? `${rawPayload.slice(0, 2000)}... (truncated)` ? `${rawPayload.slice(0, 2000)}... (truncated)`
: rawPayload; : rawPayload;
this.logger.error( log.error(
{ {
err, err,
rawPayload: trimmedRawPayload, rawPayload: trimmedRawPayload,
@@ -825,6 +990,16 @@ export class VoiceAssistantWebSocketServer {
"Failed to parse/handle message" "Failed to parse/handle message"
); );
if (this.pendingConnections.has(ws)) {
this.clearPendingConnection(ws);
try {
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
} catch {
// ignore close errors
}
return;
}
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload); const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload);
if (requestInfo) { if (requestInfo) {
this.sendToClient( this.sendToClient(
@@ -952,7 +1127,6 @@ type SocketRequestMetadata = {
origin?: string; origin?: string;
userAgent?: string; userAgent?: string;
remoteAddress?: string; remoteAddress?: string;
clientSessionKey?: string;
}; };
function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
@@ -983,30 +1157,12 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
typeof record.socket?.remoteAddress === "string" typeof record.socket?.remoteAddress === "string"
? record.socket.remoteAddress ? record.socket.remoteAddress
: undefined; : undefined;
const rawUrl = typeof record.url === "string" ? record.url : null;
const clientSessionKey = (() => {
if (!rawUrl) {
return undefined;
}
try {
const parsed = new URL(rawUrl, "http://localhost");
const value = parsed.searchParams.get("clientSessionKey");
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
} catch {
return undefined;
}
})();
return { return {
...(host ? { host } : {}), ...(host ? { host } : {}),
...(origin ? { origin } : {}), ...(origin ? { origin } : {}),
...(userAgent ? { userAgent } : {}), ...(userAgent ? { userAgent } : {}),
...(remoteAddress ? { remoteAddress } : {}), ...(remoteAddress ? { remoteAddress } : {}),
...(clientSessionKey ? { clientSessionKey } : {}),
}; };
} }
@@ -13,12 +13,24 @@ describe("relay websocket URL versioning", () => {
endpoint: "relay.paseo.sh:443", endpoint: "relay.paseo.sh:443",
serverId: "srv_test", serverId: "srv_test",
role: "client", role: "client",
clientSessionKey: "clsk_test",
}) })
); );
expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION); expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION);
expect(url.searchParams.get("clientId")).toBe("clsk_test"); expect(url.searchParams.has("connectionId")).toBe(false);
});
test("includes connectionId when provided (server data sockets)", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
serverId: "srv_test",
role: "server",
connectionId: "conn_abc123",
})
);
expect(url.searchParams.get("connectionId")).toBe("conn_abc123");
}); });
test("allows explicitly requesting v1 relay URLs", () => { test("allows explicitly requesting v1 relay URLs", () => {
+9 -22
View File
@@ -83,28 +83,23 @@ function shouldUseSecureWebSocket(port: number): boolean {
} }
export function buildDaemonWebSocketUrl( export function buildDaemonWebSocketUrl(
endpoint: string, endpoint: string
params?: { clientSessionKey?: string }
): string { ): string {
const { host, port, isIpv6 } = parseHostPort(endpoint); const { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host; const hostPart = isIpv6 ? `[${host}]` : host;
const url = new URL(`${protocol}://${hostPart}:${port}/ws`); return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
if (
typeof params?.clientSessionKey === "string" &&
params.clientSessionKey.trim().length > 0
) {
url.searchParams.set("clientSessionKey", params.clientSessionKey.trim());
}
return url.toString();
} }
export function buildRelayWebSocketUrl(params: { export function buildRelayWebSocketUrl(params: {
endpoint: string; endpoint: string;
serverId: string; serverId: string;
role: RelayRole; role: RelayRole;
clientId?: string; /**
clientSessionKey?: string; * Per-connection routing identifier used by the daemon to open server data sockets.
* Clients should NOT provide this — the relay assigns a routing ID on connect.
*/
connectionId?: string;
version?: RelayProtocolVersion | 1 | 2; version?: RelayProtocolVersion | 1 | 2;
}): string { }): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint); const { host, port, isIpv6 } = parseHostPort(params.endpoint);
@@ -114,16 +109,8 @@ export function buildRelayWebSocketUrl(params: {
url.searchParams.set("serverId", params.serverId); url.searchParams.set("serverId", params.serverId);
url.searchParams.set("role", params.role); url.searchParams.set("role", params.role);
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version)); url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
if ( if (params.connectionId) {
params.clientId && url.searchParams.set("connectionId", params.connectionId);
params.clientSessionKey &&
params.clientId !== params.clientSessionKey
) {
throw new Error("clientId and clientSessionKey must match when both are provided");
}
const resolvedClientId = params.clientId ?? params.clientSessionKey;
if (resolvedClientId) {
url.searchParams.set("clientId", resolvedClientId);
} }
return url.toString(); return url.toString();
} }
@@ -78,6 +78,48 @@ describe('shared messages stream parsing', () => {
} }
}) })
it('parses representative sub_agent tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({
type: 'agent_stream',
payload: {
agentId: 'agent_live',
timestamp: '2026-02-08T20:10:00.000Z',
event: {
type: 'timeline',
provider: 'claude',
item: {
type: 'tool_call',
callId: 'call_sub_agent_live',
name: 'Task',
status: 'running',
detail: {
type: 'sub_agent',
subAgentType: 'Explore',
description: 'Inspect repository structure',
log: '[Read] README.md',
actions: [
{
index: 1,
toolName: 'Read',
summary: 'README.md',
},
],
},
error: null,
},
},
},
})
expect(parsed.payload.event.type).toBe('timeline')
if (parsed.payload.event.type === 'timeline') {
expect(parsed.payload.event.item.type).toBe('tool_call')
if (parsed.payload.event.item.type === 'tool_call') {
expect(parsed.payload.event.item.detail.type).toBe('sub_agent')
}
}
})
it('rejects removed initialize_agent_request inbound payload', () => { it('rejects removed initialize_agent_request inbound payload', () => {
const parsed = SessionInboundMessageSchema.safeParse({ const parsed = SessionInboundMessageSchema.safeParse({
type: 'initialize_agent_request', type: 'initialize_agent_request',
@@ -109,4 +109,41 @@ describe("shared messages tool_call schema", () => {
expect(missingDetail.success).toBe(false); expect(missingDetail.success).toBe(false);
expect(legacyStatus.success).toBe(false); expect(legacyStatus.success).toBe(false);
}); });
it("parses canonical sub_agent detail payload", () => {
const parsed = AgentTimelineItemPayloadSchema.parse({
type: "tool_call",
callId: "call_sub_agent_1",
name: "Task",
status: "running",
error: null,
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",
},
],
},
});
expect(parsed.type).toBe("tool_call");
if (parsed.type === "tool_call") {
expect(parsed.detail.type).toBe("sub_agent");
if (parsed.detail.type === "sub_agent") {
expect(parsed.detail.subAgentType).toBe("Explore");
expect(parsed.detail.actions).toHaveLength(2);
}
}
});
}); });
@@ -38,24 +38,29 @@ describe("shared tool-call display mapping", () => {
}); });
}); });
it("keeps task metadata summary on unknown detail", () => { it("uses sub-agent detail for task label and description", () => {
const display = buildToolCallDisplayModel({ const display = buildToolCallDisplayModel({
name: "task", name: "task",
status: "running", status: "running",
error: null, error: null,
detail: { detail: {
type: "unknown", type: "sub_agent",
input: null, subAgentType: "Explore",
output: null, description: "Inspect repository structure",
}, log: "[Read] README.md",
metadata: { actions: [
subAgentActivity: "Running tests", {
index: 1,
toolName: "Read",
summary: "README.md",
},
],
}, },
}); });
expect(display).toEqual({ expect(display).toEqual({
displayName: "Task", displayName: "Explore",
summary: "Running tests", summary: "Inspect repository structure",
}); });
}); });
@@ -87,6 +87,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
displayName = "Worktree Setup"; displayName = "Worktree Setup";
summary = input.detail.branchName; summary = input.detail.branchName;
break; break;
case "sub_agent":
displayName = readString(input.detail.subAgentType) ?? "Task";
summary = readString(input.detail.description);
break;
case "unknown": case "unknown":
break; break;
} }