refactor: migrate from clientSessionKey to clientId identity
This commit is contained in:
@@ -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(() => {
|
||||
const previousUpdatedAt = latestAgentUpdatedAtRef.current
|
||||
if (agentUpdatedAtMs < previousUpdatedAt) {
|
||||
if (isProcessing && !isAgentRunning) {
|
||||
prevIsAgentRunningRef.current = false
|
||||
setIsProcessing(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ export function ToolCallDetailsContent({
|
||||
(detail?.type === "shell" ||
|
||||
detail?.type === "edit" ||
|
||||
detail?.type === "write" ||
|
||||
detail?.type === "read");
|
||||
detail?.type === "read" ||
|
||||
detail?.type === "sub_agent");
|
||||
const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer;
|
||||
|
||||
if (detail?.type === "shell") {
|
||||
@@ -126,6 +127,45 @@ export function ToolCallDetailsContent({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "sub_agent") {
|
||||
const activityLog = detail.log.replace(/^\n+/, "");
|
||||
const hasLog = activityLog.length > 0;
|
||||
const fallbackHeader =
|
||||
detail.subAgentType && detail.description
|
||||
? `${detail.subAgentType}: ${detail.description}`
|
||||
: detail.subAgentType ?? detail.description ?? "Sub-agent activity";
|
||||
sections.push(
|
||||
<View
|
||||
key="sub-agent"
|
||||
style={[styles.section, shouldFill && styles.fillHeight]}
|
||||
>
|
||||
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog ? activityLog : fallbackHeader}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "edit") {
|
||||
sections.push(
|
||||
<View
|
||||
|
||||
@@ -445,8 +445,6 @@ function SessionProviderInternal({
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
@@ -469,15 +467,53 @@ function SessionProviderInternal({
|
||||
setAgentLastActivity(agent.id, agent.lastActivityAt);
|
||||
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [key, pending] of Array.from(next.entries())) {
|
||||
const existingKeysForAgent: string[] = [];
|
||||
for (const [key, pending] of prev.entries()) {
|
||||
if (pending.agentId === agent.id) {
|
||||
next.delete(key);
|
||||
existingKeysForAgent.push(key);
|
||||
}
|
||||
}
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
next.set(key, { key, agentId: agent.id, request });
|
||||
|
||||
const nextEntries = agent.pendingPermissions.map((request) => ({
|
||||
key: derivePendingPermissionKey(agent.id, request),
|
||||
agentId: agent.id,
|
||||
request,
|
||||
}));
|
||||
|
||||
let changed = existingKeysForAgent.length !== nextEntries.length;
|
||||
if (!changed) {
|
||||
const existingKeySet = new Set(existingKeysForAgent);
|
||||
for (const entry of nextEntries) {
|
||||
const existing = prev.get(entry.key);
|
||||
if (!existingKeySet.has(entry.key) || !existing) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentRequest = existing.request;
|
||||
if (
|
||||
currentRequest.id !== entry.request.id ||
|
||||
currentRequest.kind !== entry.request.kind ||
|
||||
currentRequest.name !== entry.request.name ||
|
||||
currentRequest.title !== entry.request.title ||
|
||||
currentRequest.description !== entry.request.description
|
||||
) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
for (const key of existingKeysForAgent) {
|
||||
next.delete(key);
|
||||
}
|
||||
for (const entry of nextEntries) {
|
||||
next.set(entry.key, entry);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -47,10 +47,9 @@ export function useAllAgentsList(options?: {
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
const liveAgents = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.agents ?? null : null
|
||||
);
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const { snapshot } = useHostRuntimeSession(serverId ?? "");
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
|
||||
@@ -168,10 +168,9 @@ export function useSidebarAgentsList(options?: {
|
||||
[options?.selectedProjectFilterKeys]
|
||||
);
|
||||
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
const liveAgents = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.agents ?? null : null
|
||||
);
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const { snapshot } = useHostRuntimeSession(serverId ?? "");
|
||||
|
||||
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||
|
||||
@@ -134,7 +134,7 @@ function makeDeps(
|
||||
}
|
||||
return value;
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("HostRuntimeController", () => {
|
||||
measureLatency: async () => {
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
@@ -188,7 +188,7 @@ describe("HostRuntimeController", () => {
|
||||
expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading");
|
||||
});
|
||||
|
||||
it("passes resolved client session key into created active clients", async () => {
|
||||
it("passes resolved client id into created active clients", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
@@ -198,23 +198,23 @@ describe("HostRuntimeController", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const seenSessionKeys: string[] = [];
|
||||
const seenClientIds: string[] = [];
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
createClient: ({ clientSessionKey }) => {
|
||||
seenSessionKeys.push(clientSessionKey);
|
||||
createClient: ({ clientId }) => {
|
||||
seenClientIds.push(clientId);
|
||||
return fakeClient as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async () => 10,
|
||||
getClientSessionKey: async () => "clsk_runtime_stable",
|
||||
getClientId: async () => "cid_runtime_stable",
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
|
||||
expect(seenSessionKeys).toEqual(["clsk_runtime_stable"]);
|
||||
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
});
|
||||
|
||||
@@ -535,7 +535,7 @@ describe("HostRuntimeController", () => {
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async () => 10,
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
@@ -621,7 +621,7 @@ describe("HostRuntimeController", () => {
|
||||
}
|
||||
throw new Error("unexpected probe call");
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -685,7 +685,7 @@ describe("HostRuntimeController", () => {
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -730,7 +730,7 @@ describe("HostRuntimeStore", () => {
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -772,7 +772,7 @@ describe("HostRuntimeStore", () => {
|
||||
measureLatency: async () => {
|
||||
throw new Error("probe unavailable");
|
||||
},
|
||||
getClientSessionKey: async () => "clsk_test_runtime",
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
buildRelayWebSocketUrl,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import { getOrCreateClientSessionKey } from "@/utils/client-session-key";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import {
|
||||
selectBestConnection,
|
||||
type ConnectionCandidate,
|
||||
@@ -118,14 +118,14 @@ export type HostRuntimeControllerDeps = {
|
||||
createClient: (input: {
|
||||
host: HostProfile;
|
||||
connection: HostConnection;
|
||||
clientSessionKey: string;
|
||||
clientId: string;
|
||||
runtimeGeneration: number;
|
||||
}) => DaemonClient;
|
||||
measureLatency: (input: {
|
||||
host: HostProfile;
|
||||
connection: HostConnection;
|
||||
}) => Promise<number>;
|
||||
getClientSessionKey: () => Promise<string>;
|
||||
getClientId: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type HostRuntimeStartOptions = {
|
||||
@@ -374,11 +374,12 @@ function selectInitialConnectionId(host: HostProfile): string | null {
|
||||
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientSessionKey, runtimeGeneration }) => {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
clientSessionKey,
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
runtimeGeneration,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
@@ -387,7 +388,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
if (connection.type === "direct") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
@@ -395,7 +396,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
clientSessionKey,
|
||||
}),
|
||||
e2ee: {
|
||||
enabled: true,
|
||||
@@ -405,7 +405,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
},
|
||||
measureLatency: ({ host, connection }) =>
|
||||
measureConnectionLatency(connection, { serverId: host.serverId }),
|
||||
getClientSessionKey: () => getOrCreateClientSessionKey(),
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,8 +421,8 @@ export class HostRuntimeController {
|
||||
private started = false;
|
||||
private switchCandidateConnectionId: string | null = null;
|
||||
private switchCandidateHitCount = 0;
|
||||
private clientSessionKeyPromise: Promise<string> | null = null;
|
||||
private clientSessionKeyHash: string | null = null;
|
||||
private clientIdPromise: Promise<string> | null = null;
|
||||
private clientIdHash: string | null = null;
|
||||
private switchRequestVersion = 0;
|
||||
private probeRequestVersion = 0;
|
||||
|
||||
@@ -740,7 +740,7 @@ export class HostRuntimeController {
|
||||
: toReasonCode(reason);
|
||||
console.info("[HostRuntimeTransition]", {
|
||||
serverId: this.host.serverId,
|
||||
clientSessionKeyHash: this.clientSessionKeyHash,
|
||||
clientIdHash: this.clientIdHash,
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
event: event.type,
|
||||
@@ -782,9 +782,9 @@ export class HostRuntimeController {
|
||||
}
|
||||
const requestVersion = ++this.switchRequestVersion;
|
||||
|
||||
let clientSessionKey: string;
|
||||
let clientId: string;
|
||||
try {
|
||||
clientSessionKey = await this.resolveClientSessionKey();
|
||||
clientId = await this.resolveClientId();
|
||||
} catch (error) {
|
||||
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
||||
return;
|
||||
@@ -792,7 +792,7 @@ export class HostRuntimeController {
|
||||
const message = toErrorMessage(error);
|
||||
this.applyConnectionEvent({
|
||||
type: "connect_failed",
|
||||
message: `Failed to resolve client session key: ${message}`,
|
||||
message: `Failed to resolve client id: ${message}`,
|
||||
});
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState),
|
||||
@@ -827,7 +827,7 @@ export class HostRuntimeController {
|
||||
const client = this.deps.createClient({
|
||||
host: this.host,
|
||||
connection,
|
||||
clientSessionKey,
|
||||
clientId,
|
||||
runtimeGeneration: nextGeneration,
|
||||
});
|
||||
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
||||
@@ -910,14 +910,14 @@ export class HostRuntimeController {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveClientSessionKey(): Promise<string> {
|
||||
if (!this.clientSessionKeyPromise) {
|
||||
this.clientSessionKeyPromise = this.deps.getClientSessionKey().then((value) => {
|
||||
this.clientSessionKeyHash = hashForLog(value);
|
||||
private resolveClientId(): Promise<string> {
|
||||
if (!this.clientIdPromise) {
|
||||
this.clientIdPromise = this.deps.getClientId().then((value) => {
|
||||
this.clientIdHash = hashForLog(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
return this.clientSessionKeyPromise;
|
||||
return this.clientIdPromise;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,85 @@ describe("stream reducer canonical tool calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sub_agent detail through lifecycle updates for the same callId", () => {
|
||||
const callId = "task-sub-agent-1";
|
||||
const updates = [
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "claude",
|
||||
callId,
|
||||
name: "Task",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md\n[Bash] ls",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
toolName: "Bash",
|
||||
summary: "ls",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:12:00Z"),
|
||||
},
|
||||
{
|
||||
event: canonicalToolTimeline({
|
||||
provider: "claude",
|
||||
callId,
|
||||
name: "Task",
|
||||
status: "completed",
|
||||
input: null,
|
||||
output: { ok: true },
|
||||
}),
|
||||
timestamp: new Date("2025-01-01T10:12:01Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const state = hydrateStreamState(updates);
|
||||
const tools = state.filter(isAgentToolCallItem);
|
||||
|
||||
assert.strictEqual(tools.length, 1);
|
||||
assert.strictEqual(tools[0].payload.data.status, "completed");
|
||||
assert.deepStrictEqual(tools[0].payload.data.detail, {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md\n[Bash] ls",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
toolName: "Bash",
|
||||
summary: "ls",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: tools[0].payload.data.name,
|
||||
status: tools[0].payload.data.status,
|
||||
error: tools[0].payload.data.error,
|
||||
detail: tools[0].payload.data.detail,
|
||||
});
|
||||
assert.deepStrictEqual(display, {
|
||||
displayName: "Explore",
|
||||
summary: "Inspect repository structure",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes shell summary from running input before completion", () => {
|
||||
const callId = "running-summary-shell";
|
||||
const state = hydrateStreamState([
|
||||
|
||||
+12
-12
@@ -9,35 +9,35 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
describe("client-session-key", () => {
|
||||
describe("client-id", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
});
|
||||
|
||||
it("returns stored client session key when present", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue("clsk_existing");
|
||||
const mod = await import("./client-session-key");
|
||||
it("returns stored client id when present", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue("cid_existing");
|
||||
const mod = await import("./client-id");
|
||||
|
||||
const key = await mod.getOrCreateClientSessionKey();
|
||||
expect(key).toBe("clsk_existing");
|
||||
const key = await mod.getOrCreateClientId();
|
||||
expect(key).toBe("cid_existing");
|
||||
expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1);
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates and persists a client session key when missing", async () => {
|
||||
it("creates and persists a client id when missing", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
|
||||
|
||||
const mod = await import("./client-session-key");
|
||||
const key = await mod.getOrCreateClientSessionKey();
|
||||
const mod = await import("./client-id");
|
||||
const key = await mod.getOrCreateClientId();
|
||||
|
||||
expect(key).toBe("clsk_123456781234123412341234567890ab");
|
||||
expect(key).toBe("cid_123456781234123412341234567890ab");
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:client-session-key-v1",
|
||||
"clsk_123456781234123412341234567890ab"
|
||||
"@paseo:client-id-v1",
|
||||
"cid_123456781234123412341234567890ab"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1";
|
||||
|
||||
let cachedClientId: string | null = null;
|
||||
let inFlightClientId: Promise<string> | null = null;
|
||||
|
||||
function generateClientId(): string {
|
||||
const randomUuid = (() => {
|
||||
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
||||
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
|
||||
return cryptoObj.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
})();
|
||||
return `cid_${randomUuid}`;
|
||||
}
|
||||
|
||||
function normalizeStoredClientId(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function getOrCreateClientId(): Promise<string> {
|
||||
if (cachedClientId) {
|
||||
return cachedClientId;
|
||||
}
|
||||
if (inFlightClientId) {
|
||||
return inFlightClientId;
|
||||
}
|
||||
|
||||
inFlightClientId = (async () => {
|
||||
const storedValue = await AsyncStorage.getItem(CLIENT_ID_STORAGE_KEY);
|
||||
const existing = normalizeStoredClientId(storedValue);
|
||||
if (existing) {
|
||||
cachedClientId = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextValue = generateClientId();
|
||||
await AsyncStorage.setItem(CLIENT_ID_STORAGE_KEY, nextValue);
|
||||
cachedClientId = nextValue;
|
||||
return nextValue;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await inFlightClientId;
|
||||
} finally {
|
||||
inFlightClientId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const CLIENT_SESSION_KEY_STORAGE_KEY = "@paseo:client-session-key-v1";
|
||||
|
||||
let cachedClientSessionKey: string | null = null;
|
||||
let inFlightClientSessionKey: Promise<string> | null = null;
|
||||
|
||||
function generateClientSessionKey(): string {
|
||||
const randomUuid = (() => {
|
||||
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
||||
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
|
||||
return cryptoObj.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
})();
|
||||
return `clsk_${randomUuid}`;
|
||||
}
|
||||
|
||||
function normalizeStoredClientSessionKey(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function getOrCreateClientSessionKey(): Promise<string> {
|
||||
if (cachedClientSessionKey) {
|
||||
return cachedClientSessionKey;
|
||||
}
|
||||
if (inFlightClientSessionKey) {
|
||||
return inFlightClientSessionKey;
|
||||
}
|
||||
|
||||
inFlightClientSessionKey = (async () => {
|
||||
const storedValue = await AsyncStorage.getItem(CLIENT_SESSION_KEY_STORAGE_KEY);
|
||||
const existing = normalizeStoredClientSessionKey(storedValue);
|
||||
if (existing) {
|
||||
cachedClientSessionKey = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nextValue = generateClientSessionKey();
|
||||
await AsyncStorage.setItem(CLIENT_SESSION_KEY_STORAGE_KEY, nextValue);
|
||||
cachedClientSessionKey = nextValue;
|
||||
return nextValue;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await inFlightClientSessionKey;
|
||||
} finally {
|
||||
inFlightClientSessionKey = null;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
clientSessionKey?: string;
|
||||
}): string {
|
||||
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const daemonClientMock = vi.hoisted(() => {
|
||||
const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = [];
|
||||
const createdConfigs: Array<{ clientId?: string; url?: string }> = [];
|
||||
|
||||
class MockDaemonClient {
|
||||
private statusHandlers = new Set<
|
||||
(
|
||||
message: {
|
||||
type: "status";
|
||||
payload: { status: string; serverId: string; hostname: string | null };
|
||||
}
|
||||
) => void
|
||||
>();
|
||||
|
||||
public lastError: string | null = null;
|
||||
private lastWelcome = {
|
||||
type: "welcome" as const,
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host" as string | null,
|
||||
version: "0.0.0",
|
||||
resumed: false,
|
||||
};
|
||||
|
||||
constructor(config: { clientSessionKey?: string; url?: string }) {
|
||||
constructor(config: { clientId?: string; url?: string }) {
|
||||
createdConfigs.push(config);
|
||||
}
|
||||
|
||||
@@ -23,35 +21,16 @@ const daemonClientMock = vi.hoisted(() => {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
on(
|
||||
event: "status",
|
||||
handler: (
|
||||
message: {
|
||||
type: "status";
|
||||
payload: { status: string; serverId: string; hostname: string | null };
|
||||
}
|
||||
) => void
|
||||
): () => void {
|
||||
if (event === "status") {
|
||||
this.statusHandlers.add(handler);
|
||||
}
|
||||
return () => {
|
||||
this.statusHandlers.delete(handler);
|
||||
};
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const message = {
|
||||
type: "status" as const,
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
},
|
||||
};
|
||||
for (const handler of this.statusHandlers) {
|
||||
handler(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getLastWelcomeMessage() {
|
||||
return this.lastWelcome;
|
||||
}
|
||||
|
||||
async ping(): Promise<{ rttMs: number }> {
|
||||
@@ -78,7 +57,7 @@ describe("test-daemon-connection probe client identity", () => {
|
||||
daemonClientMock.createdConfigs.length = 0;
|
||||
});
|
||||
|
||||
it("uses isolated probe clientSessionKey values for direct latency probes", async () => {
|
||||
it("uses isolated probe clientId values for direct latency probes", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
await mod.measureConnectionLatency({
|
||||
@@ -93,8 +72,8 @@ describe("test-daemon-connection probe client identity", () => {
|
||||
});
|
||||
|
||||
const [first, second] = daemonClientMock.createdConfigs;
|
||||
expect(first?.clientSessionKey).toMatch(/^clsk_probe_/);
|
||||
expect(second?.clientSessionKey).toMatch(/^clsk_probe_/);
|
||||
expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey);
|
||||
expect(first?.clientId).toMatch(/^cid_probe_/);
|
||||
expect(second?.clientId).toMatch(/^cid_probe_/);
|
||||
expect(first?.clientId).not.toBe(second?.clientId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||
import type { HostConnection } from "@/contexts/daemon-registry-context";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||
function createProbeClientSessionKey(): string {
|
||||
function createProbeClientId(): string {
|
||||
const randomUuid = (() => {
|
||||
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
||||
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
|
||||
@@ -12,7 +11,7 @@ function createProbeClientSessionKey(): string {
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
})();
|
||||
return `clsk_probe_${randomUuid}`;
|
||||
return `cid_probe_${randomUuid}`;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
@@ -55,10 +54,11 @@ async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientSessionKey = createProbeClientSessionKey();
|
||||
const clientId = createProbeClientId();
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const base = {
|
||||
clientSessionKey,
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
suppressSendErrors: true,
|
||||
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
|
||||
};
|
||||
@@ -66,7 +66,7 @@ async function buildClientConfig(
|
||||
if (connection.type === "direct") {
|
||||
return {
|
||||
...base,
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ async function buildClientConfig(
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId,
|
||||
clientSessionKey,
|
||||
}),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
||||
};
|
||||
@@ -92,35 +91,9 @@ function connectAndProbe(
|
||||
const client = new DaemonClient(config);
|
||||
|
||||
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => {
|
||||
let cleanedUp = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let unsubscribeStatus: (() => void) | null = null;
|
||||
let serverId: string | null = null;
|
||||
let hostname: string | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe?.();
|
||||
unsubscribeStatus?.();
|
||||
};
|
||||
|
||||
const maybeFinishOk = () => {
|
||||
if (!serverId) return;
|
||||
cleanup();
|
||||
resolve({ client, serverId, hostname });
|
||||
};
|
||||
|
||||
const finishErr = (error: Error) => {
|
||||
if (cleanedUp) return;
|
||||
cleanup();
|
||||
client.close().catch(() => undefined);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finishErr(
|
||||
void client.close().catch(() => undefined);
|
||||
reject(
|
||||
new DaemonConnectionTestError("Connection timed out", {
|
||||
reason: "Connection timed out",
|
||||
lastError: client.lastError ?? null,
|
||||
@@ -128,25 +101,32 @@ function connectAndProbe(
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||
if (state.status === "disconnected") {
|
||||
const reason = normalizeNonEmptyString(state.reason);
|
||||
const lastError = normalizeNonEmptyString(client.lastError);
|
||||
const message = pickBestReason(reason, lastError);
|
||||
finishErr(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||
void client.connect().then(() => {
|
||||
clearTimeout(timer);
|
||||
const welcome = client.getLastWelcomeMessage();
|
||||
if (!welcome) {
|
||||
void client.close().catch(() => undefined);
|
||||
reject(
|
||||
new DaemonConnectionTestError("Missing welcome message", {
|
||||
reason: "Missing welcome message",
|
||||
lastError: client.lastError ?? null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
client,
|
||||
serverId: welcome.serverId,
|
||||
hostname: welcome.hostname,
|
||||
});
|
||||
}).catch((error) => {
|
||||
clearTimeout(timer);
|
||||
const reason = normalizeNonEmptyString(error instanceof Error ? error.message : String(error));
|
||||
const lastError = normalizeNonEmptyString(client.lastError);
|
||||
const message = pickBestReason(reason, lastError);
|
||||
void client.close().catch(() => undefined);
|
||||
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||
});
|
||||
|
||||
unsubscribeStatus = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const payload = parseServerInfoStatusPayload(message.payload);
|
||||
if (!payload) return;
|
||||
serverId = payload.serverId;
|
||||
hostname = payload.hostname;
|
||||
maybeFinishOk();
|
||||
});
|
||||
|
||||
void client.connect().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,24 +38,29 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses metadata summary for task tool calls", () => {
|
||||
it("uses sub-agent detail for task label and description", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
displayName: "Explore",
|
||||
summary: "Inspect repository structure",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const iconMocks = vi.hoisted(() => ({
|
||||
Bot: () => null,
|
||||
Brain: () => null,
|
||||
Eye: () => null,
|
||||
MicVocal: () => null,
|
||||
Pencil: () => null,
|
||||
Search: () => null,
|
||||
SquareTerminal: () => null,
|
||||
Wrench: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => iconMocks);
|
||||
|
||||
import { resolveToolCallIcon } from "./tool-call-icon";
|
||||
|
||||
describe("tool-call-icon", () => {
|
||||
it("uses robot icon for task sub-agent details", () => {
|
||||
const icon = resolveToolCallIcon("Task", {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository",
|
||||
log: "[Read] README.md",
|
||||
actions: [{ index: 1, toolName: "Read", summary: "README.md" }],
|
||||
});
|
||||
|
||||
expect(icon).toBe(iconMocks.Bot);
|
||||
});
|
||||
|
||||
it("uses robot icon for task calls without canonical detail", () => {
|
||||
const icon = resolveToolCallIcon("Task", {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
});
|
||||
|
||||
expect(icon).toBe(iconMocks.Bot);
|
||||
expect(resolveToolCallIcon("Task")).toBe(iconMocks.Bot);
|
||||
});
|
||||
|
||||
it("keeps thinking icon override for unknown detail", () => {
|
||||
const icon = resolveToolCallIcon("thinking", {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
});
|
||||
|
||||
expect(icon).toBe(iconMocks.Brain);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
worktree_setup: SquareTerminal,
|
||||
sub_agent: Bot,
|
||||
unknown: Wrench,
|
||||
};
|
||||
|
||||
@@ -33,13 +34,12 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
|
||||
if (lowerName === "speak") {
|
||||
return MicVocal;
|
||||
}
|
||||
if (lowerName === "task") {
|
||||
return Bot;
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
return TOOL_DETAIL_ICONS[detail.type];
|
||||
}
|
||||
|
||||
if (lowerName === "task") {
|
||||
return Bot;
|
||||
}
|
||||
return Wrench;
|
||||
}
|
||||
|
||||
+12
-12
@@ -5,31 +5,31 @@ import { homedir } from "node:os";
|
||||
|
||||
const CLIENT_SESSION_KEY_FILE = join(
|
||||
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();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function generateClientSessionKey(): string {
|
||||
return `clsk_${randomUUID().replace(/-/g, "")}`;
|
||||
function generateClientId(): string {
|
||||
return `cid_${randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
export async function getOrCreateCliClientSessionKey(): Promise<string> {
|
||||
if (cachedClientSessionKey) {
|
||||
return cachedClientSessionKey;
|
||||
export async function getOrCreateCliClientId(): Promise<string> {
|
||||
if (cachedClientId) {
|
||||
return cachedClientId;
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = normalizeClientSessionKey(
|
||||
const existing = normalizeClientId(
|
||||
await readFile(CLIENT_SESSION_KEY_FILE, "utf8")
|
||||
);
|
||||
if (existing) {
|
||||
cachedClientSessionKey = existing;
|
||||
cachedClientId = existing;
|
||||
return existing;
|
||||
}
|
||||
} 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 writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 });
|
||||
cachedClientSessionKey = nextValue;
|
||||
cachedClientId = nextValue;
|
||||
return nextValue;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DaemonClient } from '@getpaseo/server'
|
||||
import WebSocket from 'ws'
|
||||
import { getOrCreateCliClientSessionKey } from './client-session-key.js'
|
||||
import { getOrCreateCliClientId } from './client-id.js'
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string
|
||||
@@ -40,15 +40,18 @@ function createNodeWebSocketFactory() {
|
||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||
const host = getDaemonHost(options)
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||
const clientSessionKey = await getOrCreateCliClientSessionKey()
|
||||
const encodedSessionKey = encodeURIComponent(clientSessionKey)
|
||||
const url = `ws://${host}/ws?clientSessionKey=${encodedSessionKey}`
|
||||
const clientId = await getOrCreateCliClientId()
|
||||
const url = `ws://${host}/ws`
|
||||
|
||||
const client = new DaemonClient({
|
||||
url,
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
const client = new DaemonClient(
|
||||
{
|
||||
url,
|
||||
clientId,
|
||||
clientType: 'cli',
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
reconnect: { enabled: false },
|
||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
|
||||
)
|
||||
|
||||
// Connect with timeout
|
||||
const connectPromise = client.connect()
|
||||
|
||||
@@ -69,7 +69,7 @@ async function withMockWebSocketPair(
|
||||
}
|
||||
|
||||
describe("RelayDurableObject versioning", () => {
|
||||
it("accepts legacy v1 client sockets without clientId", async () => {
|
||||
it("accepts legacy v1 client sockets without connectionId", async () => {
|
||||
const { state } = createMockState();
|
||||
await withMockWebSocketPair(async () => {
|
||||
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 relay = new RelayDurableObject(state as any);
|
||||
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2");
|
||||
const response = await relay.fetch(req);
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe("Missing clientId parameter");
|
||||
await withMockWebSocketPair(async ({ serverWs }) => {
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2", {
|
||||
headers: { Upgrade: "websocket" },
|
||||
});
|
||||
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}`, []);
|
||||
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
(relay as any).nudgeOrResetControlForClient(clientId);
|
||||
(relay as any).nudgeOrResetControlForConnection(clientId);
|
||||
|
||||
vi.advanceTimersByTime(15_000);
|
||||
|
||||
@@ -124,7 +132,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
const control = createMockSocket();
|
||||
const client = createMockSocket({
|
||||
role: "client",
|
||||
clientId,
|
||||
connectionId: clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
@@ -136,7 +144,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
setTagSockets(`server:${clientId}`, []);
|
||||
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
(relay as any).nudgeOrResetControlForClient(clientId);
|
||||
(relay as any).nudgeOrResetControlForConnection(clientId);
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(control.send).toHaveBeenCalledTimes(1);
|
||||
@@ -145,11 +153,11 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
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({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId: "clt_same_session",
|
||||
connectionId: "clt_same_session",
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
@@ -160,7 +168,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
await withMockWebSocketPair(async () => {
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
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: {
|
||||
Upgrade: "websocket",
|
||||
@@ -178,14 +186,14 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
const disconnectedClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId,
|
||||
connectionId: clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const stillConnectedClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId,
|
||||
connectionId: clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
@@ -208,7 +216,7 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
|
||||
expect(serverData.close).not.toHaveBeenCalled();
|
||||
expect(control.send).not.toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "client_disconnected", clientId })
|
||||
JSON.stringify({ type: "disconnected", connectionId: clientId })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,9 +74,9 @@ interface DurableObjectStub {
|
||||
* - role=client: app/client socket
|
||||
*
|
||||
* v2 WebSockets connect in three shapes:
|
||||
* - role=server (no clientId): daemon control socket (one per serverId)
|
||||
* - role=server&clientId=...: daemon per-client data socket (one per clientId)
|
||||
* - role=client&clientId=...: app/client socket (many per clientId)
|
||||
* - role=server (no connectionId): daemon control socket (one per serverId)
|
||||
* - role=server&connectionId=...: daemon per-connection data socket (one per connectionId)
|
||||
* - role=client&connectionId=...: app/client socket (many per connectionId)
|
||||
*/
|
||||
interface CFResponseInit extends ResponseInit {
|
||||
webSocket?: WebSocket;
|
||||
@@ -84,7 +84,7 @@ interface CFResponseInit extends ResponseInit {
|
||||
|
||||
export class RelayDurableObject {
|
||||
private state: DurableObjectState;
|
||||
private pendingClientFrames = new Map<string, Array<string | ArrayBuffer>>();
|
||||
private pendingFrames = new Map<string, Array<string | ArrayBuffer>>();
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.state = state;
|
||||
@@ -110,42 +110,42 @@ export class RelayDurableObject {
|
||||
} as CFResponseInit);
|
||||
}
|
||||
|
||||
private hasServerDataSocket(clientId: string): boolean {
|
||||
private hasServerDataSocket(connectionId: string): boolean {
|
||||
try {
|
||||
return this.state.getWebSockets(`server:${clientId}`).length > 0;
|
||||
return this.state.getWebSockets(`server:${connectionId}`).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private hasClientSocket(clientId: string): boolean {
|
||||
private hasClientSocket(connectionId: string): boolean {
|
||||
try {
|
||||
return this.state.getWebSockets(`client:${clientId}`).length > 0;
|
||||
return this.state.getWebSockets(`client:${connectionId}`).length > 0;
|
||||
} catch {
|
||||
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
|
||||
// (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
|
||||
// socket(s) so the daemon reconnects.
|
||||
const initialDelayMs = 10_000;
|
||||
const secondDelayMs = 5_000;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.hasClientSocket(clientId)) return;
|
||||
if (this.hasServerDataSocket(clientId)) return;
|
||||
if (!this.hasClientSocket(connectionId)) return;
|
||||
if (this.hasServerDataSocket(connectionId)) return;
|
||||
|
||||
// First nudge: send a full sync list.
|
||||
this.notifyControls({ type: "sync", clientIds: this.listConnectedClientIds() });
|
||||
this.notifyControls({ type: "sync", connectionIds: this.listConnectedConnectionIds() });
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.hasClientSocket(clientId)) return;
|
||||
if (this.hasServerDataSocket(clientId)) return;
|
||||
if (!this.hasClientSocket(connectionId)) return;
|
||||
if (this.hasServerDataSocket(connectionId)) return;
|
||||
|
||||
// Still nothing: assume control is stuck and force a reconnect.
|
||||
for (const ws of this.state.getWebSockets("server-control")) {
|
||||
@@ -159,38 +159,38 @@ export class RelayDurableObject {
|
||||
}, initialDelayMs);
|
||||
}
|
||||
|
||||
private bufferClientFrame(clientId: string, message: string | ArrayBuffer): void {
|
||||
const existing = this.pendingClientFrames.get(clientId) ?? [];
|
||||
private bufferFrame(connectionId: string, message: string | ArrayBuffer): void {
|
||||
const existing = this.pendingFrames.get(connectionId) ?? [];
|
||||
existing.push(message);
|
||||
// Prevent unbounded memory growth if a daemon never connects.
|
||||
if (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 {
|
||||
const frames = this.pendingClientFrames.get(clientId);
|
||||
private flushFrames(connectionId: string, serverWs: WebSocket): void {
|
||||
const frames = this.pendingFrames.get(connectionId);
|
||||
if (!frames || frames.length === 0) return;
|
||||
this.pendingClientFrames.delete(clientId);
|
||||
this.pendingFrames.delete(connectionId);
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
serverWs.send(frame);
|
||||
} catch {
|
||||
// If we can't flush, re-buffer and let the daemon re-establish.
|
||||
this.bufferClientFrame(clientId, frame);
|
||||
this.bufferFrame(connectionId, frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private listConnectedClientIds(): string[] {
|
||||
private listConnectedConnectionIds(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const ws of this.state.getWebSockets("client")) {
|
||||
try {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
if (attachment?.role === "client" && typeof attachment.clientId === "string" && attachment.clientId) {
|
||||
out.add(attachment.clientId);
|
||||
if (attachment?.role === "client" && typeof attachment.connectionId === "string" && attachment.connectionId) {
|
||||
out.add(attachment.connectionId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -230,7 +230,7 @@ export class RelayDurableObject {
|
||||
serverId,
|
||||
role,
|
||||
version: LEGACY_RELAY_VERSION,
|
||||
clientId: null,
|
||||
connectionId: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
(server as WebSocketWithAttachment).serializeAttachment(attachment);
|
||||
@@ -244,30 +244,30 @@ export class RelayDurableObject {
|
||||
request: Request,
|
||||
role: ConnectionRole,
|
||||
serverId: string,
|
||||
clientId: string
|
||||
connectionId: string
|
||||
): 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);
|
||||
if (upgradeError) return upgradeError;
|
||||
|
||||
const isServerControl = role === "server" && !clientId;
|
||||
const isServerData = role === "server" && !!clientId;
|
||||
// If a client didn't provide a connectionId, the relay assigns one for routing.
|
||||
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.
|
||||
// - server-control: single per serverId
|
||||
// - server-data: single per clientId
|
||||
// - client: many sockets per clientId are allowed
|
||||
// - server-data: single per connectionId
|
||||
// - client: many sockets per connectionId are allowed
|
||||
if (isServerControl) {
|
||||
for (const ws of this.state.getWebSockets("server-control")) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
}
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
@@ -276,11 +276,11 @@ export class RelayDurableObject {
|
||||
|
||||
const tags: string[] = [];
|
||||
if (role === "client") {
|
||||
tags.push("client", `client:${clientId}`);
|
||||
tags.push("client", `client:${resolvedConnectionId}`);
|
||||
} else if (isServerControl) {
|
||||
tags.push("server-control");
|
||||
} else {
|
||||
tags.push("server", `server:${clientId}`);
|
||||
tags.push("server", `server:${resolvedConnectionId}`);
|
||||
}
|
||||
|
||||
this.state.acceptWebSocket(server, tags);
|
||||
@@ -289,31 +289,31 @@ export class RelayDurableObject {
|
||||
serverId,
|
||||
role,
|
||||
version: CURRENT_RELAY_VERSION,
|
||||
clientId: clientId || null,
|
||||
connectionId: resolvedConnectionId || null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
(server as WebSocketWithAttachment).serializeAttachment(attachment);
|
||||
|
||||
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") {
|
||||
this.notifyControls({ type: "client_connected", clientId });
|
||||
this.nudgeOrResetControlForClient(clientId);
|
||||
this.notifyControls({ type: "connected", connectionId: resolvedConnectionId });
|
||||
this.nudgeOrResetControlForConnection(resolvedConnectionId);
|
||||
}
|
||||
|
||||
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 {
|
||||
server.send(JSON.stringify({ type: "sync", clientIds: this.listConnectedClientIds() }));
|
||||
server.send(JSON.stringify({ type: "sync", connectionIds: this.listConnectedConnectionIds() }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (isServerData && clientId) {
|
||||
this.flushClientFrames(clientId, server);
|
||||
if (isServerData && resolvedConnectionId) {
|
||||
this.flushFrames(resolvedConnectionId, server);
|
||||
}
|
||||
|
||||
return this.asSwitchingProtocolsResponse(client);
|
||||
@@ -323,8 +323,8 @@ export class RelayDurableObject {
|
||||
const url = new URL(request.url);
|
||||
const role = url.searchParams.get("role") as ConnectionRole | null;
|
||||
const serverId = url.searchParams.get("serverId");
|
||||
const clientIdRaw = url.searchParams.get("clientId");
|
||||
const clientId = typeof clientIdRaw === "string" ? clientIdRaw.trim() : "";
|
||||
const connectionIdRaw = url.searchParams.get("connectionId");
|
||||
const connectionId = typeof connectionIdRaw === "string" ? connectionIdRaw.trim() : "";
|
||||
const version = resolveRelayVersion(url.searchParams.get("v"));
|
||||
|
||||
if (!role || (role !== "server" && role !== "client")) {
|
||||
@@ -343,7 +343,7 @@ export class RelayDurableObject {
|
||||
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;
|
||||
}
|
||||
|
||||
const { role, clientId } = attachment;
|
||||
if (!clientId) {
|
||||
const { role, connectionId } = attachment;
|
||||
if (!connectionId) {
|
||||
// Control channel: support simple app-level keepalive.
|
||||
if (typeof message === "string") {
|
||||
try {
|
||||
@@ -392,28 +392,28 @@ export class RelayDurableObject {
|
||||
}
|
||||
|
||||
if (role === "client") {
|
||||
const servers = this.state.getWebSockets(`server:${clientId}`);
|
||||
const servers = this.state.getWebSockets(`server:${connectionId}`);
|
||||
if (servers.length === 0) {
|
||||
this.bufferClientFrame(clientId, message);
|
||||
this.bufferFrame(connectionId, message);
|
||||
return;
|
||||
}
|
||||
for (const target of servers) {
|
||||
try {
|
||||
target.send(message);
|
||||
} 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;
|
||||
}
|
||||
|
||||
// server data socket -> client
|
||||
const targets = this.state.getWebSockets(`client:${clientId}`);
|
||||
const targets = this.state.getWebSockets(`client:${connectionId}`);
|
||||
for (const target of targets) {
|
||||
try {
|
||||
target.send(message);
|
||||
} 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;
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.role === "client" && attachment.clientId) {
|
||||
if (attachment.role === "client" && attachment.connectionId) {
|
||||
const remainingClientSockets = this.state
|
||||
.getWebSockets(`client:${attachment.clientId}`)
|
||||
.getWebSockets(`client:${attachment.connectionId}`)
|
||||
.some((socket) => socket !== ws);
|
||||
if (remainingClientSockets) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingClientFrames.delete(attachment.clientId);
|
||||
this.pendingFrames.delete(attachment.connectionId);
|
||||
// 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 {
|
||||
serverWs.close(1001, "Client disconnected");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.notifyControls({ type: "client_disconnected", clientId: attachment.clientId });
|
||||
this.notifyControls({ type: "disconnected", connectionId: attachment.connectionId });
|
||||
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.
|
||||
for (const clientWs of this.state.getWebSockets(`client:${attachment.clientId}`)) {
|
||||
for (const clientWs of this.state.getWebSockets(`client:${attachment.connectionId}`)) {
|
||||
try {
|
||||
clientWs.close(1012, "Server disconnected");
|
||||
} catch {
|
||||
|
||||
@@ -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 () => {
|
||||
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 ===
|
||||
// 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 timeout = setTimeout(
|
||||
() => reject(new Error("timed out waiting for client_connected")),
|
||||
() => reject(new Error("timed out waiting for connected")),
|
||||
5000
|
||||
);
|
||||
const onMessage = (raw: unknown) => {
|
||||
@@ -179,13 +179,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
? (raw as any).toString()
|
||||
: "";
|
||||
const msg = JSON.parse(text);
|
||||
if (msg?.type === "client_connected" && msg.clientId === clientId) {
|
||||
if (msg?.type === "connected" && msg.connectionId === connectionId) {
|
||||
clearTimeout(timeout);
|
||||
daemonControlWs.off("message", onMessage);
|
||||
resolve();
|
||||
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);
|
||||
daemonControlWs.off("message", onMessage);
|
||||
resolve();
|
||||
@@ -197,9 +197,9 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
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(
|
||||
`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) => {
|
||||
@@ -210,7 +210,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
await waitForClientSeen;
|
||||
|
||||
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) => {
|
||||
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 () => {
|
||||
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
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
@@ -328,7 +328,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
|
||||
const waitForClientSeen = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("timed out waiting for client_connected")),
|
||||
() => reject(new Error("timed out waiting for connected")),
|
||||
5000
|
||||
);
|
||||
const onMessage = (raw: unknown) => {
|
||||
@@ -340,13 +340,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
? (raw as any).toString()
|
||||
: "";
|
||||
const msg = JSON.parse(text);
|
||||
if (msg?.type === "client_connected" && msg.clientId === clientId) {
|
||||
if (msg?.type === "connected" && msg.connectionId === connectionId) {
|
||||
clearTimeout(timeout);
|
||||
daemonControlWs.off("message", onMessage);
|
||||
resolve();
|
||||
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);
|
||||
daemonControlWs.off("message", onMessage);
|
||||
resolve();
|
||||
@@ -359,13 +359,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
});
|
||||
|
||||
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 waitForClientSeen;
|
||||
|
||||
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));
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
await withRetry(
|
||||
async () => {
|
||||
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 serverDataUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
|
||||
serverId
|
||||
)}&role=server&clientId=${encodeURIComponent(clientId)}&v=2`;
|
||||
)}&role=server&connectionId=${encodeURIComponent(connectionId)}&v=2`;
|
||||
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(
|
||||
serverId
|
||||
)}&role=client&clientId=${encodeURIComponent(clientId)}&v=2`;
|
||||
)}&role=client&connectionId=${encodeURIComponent(connectionId)}&v=2`;
|
||||
|
||||
// === Key setup ===
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
@@ -87,13 +87,13 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("Timed out waiting for client_connected")),
|
||||
() => reject(new Error("Timed out waiting for connected")),
|
||||
10_000
|
||||
);
|
||||
daemonControlWs.on("message", (raw) => {
|
||||
try {
|
||||
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);
|
||||
resolve();
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ export interface RelaySessionAttachment {
|
||||
*/
|
||||
version?: "1" | "2";
|
||||
/**
|
||||
* Unique id for the client connection. Allows the daemon to create an
|
||||
* independent socket + E2EE channel per connected client.
|
||||
* Unique id for the connection. Allows the daemon to create an
|
||||
* independent socket + E2EE channel per connected connection.
|
||||
*/
|
||||
clientId?: string | null;
|
||||
connectionId?: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -171,7 +172,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -197,7 +199,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
connectTimeoutMs: 100,
|
||||
@@ -234,7 +237,8 @@ describe('DaemonClient', () => {
|
||||
let transportIndex = 0
|
||||
|
||||
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,
|
||||
reconnect: {
|
||||
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(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
|
||||
clientId: '',
|
||||
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', () => {
|
||||
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', () => {
|
||||
test('requires non-empty clientId for direct connections', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws',
|
||||
clientId: ' ',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
})
|
||||
|
||||
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')
|
||||
}).toThrow('Daemon client requires a non-empty clientId')
|
||||
})
|
||||
|
||||
test('logs configured runtime generation in connection transition events', async () => {
|
||||
@@ -310,7 +296,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
runtimeGeneration: 7,
|
||||
@@ -336,7 +323,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -399,7 +387,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -467,7 +456,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -526,7 +516,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -597,7 +588,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -659,7 +651,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -705,7 +698,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -786,7 +780,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -829,7 +824,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -881,7 +877,8 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
@@ -908,7 +905,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -962,7 +960,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1037,7 +1036,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1093,7 +1093,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1139,7 +1140,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1181,7 +1183,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1223,7 +1226,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1285,7 +1289,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1347,7 +1352,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1418,7 +1424,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1470,7 +1477,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1562,7 +1570,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1632,7 +1641,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1668,7 +1678,8 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AgentResumedStatusPayloadSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type WSWelcomeMessage,
|
||||
WSOutboundMessageSchema,
|
||||
} from '../shared/messages.js'
|
||||
import type {
|
||||
@@ -144,7 +145,8 @@ export type DaemonEventHandler = (event: DaemonEvent) => void
|
||||
|
||||
export type DaemonClientConfig = {
|
||||
url: string
|
||||
clientSessionKey?: string
|
||||
clientId: string
|
||||
clientType?: 'mobile' | 'browser' | 'cli' | 'mcp'
|
||||
runtimeGeneration?: number | null
|
||||
authHeader?: string
|
||||
suppressSendErrors?: boolean
|
||||
@@ -312,7 +314,7 @@ function isWaiterTimeoutError(error: unknown): boolean {
|
||||
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') {
|
||||
return null
|
||||
}
|
||||
@@ -390,8 +392,9 @@ export class DaemonClient {
|
||||
private terminalStreams: TerminalStreamManager
|
||||
private readonly logConnectionPath: 'direct' | 'relay'
|
||||
private readonly logServerId: string | null
|
||||
private readonly logClientSessionKeyHash: string
|
||||
private readonly logClientIdHash: string
|
||||
private readonly logGeneration: number | null
|
||||
private lastWelcomeMessage: WSWelcomeMessage | null = null
|
||||
|
||||
constructor(private config: DaemonClientConfig) {
|
||||
this.logger = config.logger ?? consoleLogger
|
||||
@@ -402,11 +405,16 @@ export class DaemonClient {
|
||||
} catch {
|
||||
parsedUrlForLog = null
|
||||
}
|
||||
const parsedServerIdForLog = normalizeClientSessionKey(
|
||||
const parsedServerIdForLog = normalizeClientId(
|
||||
parsedUrlForLog?.searchParams.get('serverId')
|
||||
)
|
||||
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 =
|
||||
typeof this.config.runtimeGeneration === 'number' && Number.isFinite(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,
|
||||
})
|
||||
}
|
||||
const transport = transportFactory({ url: this.config.url, headers })
|
||||
const transportUrl = this.resolveTransportUrlForAttempt()
|
||||
const transport = transportFactory({ url: transportUrl, headers })
|
||||
this.transport = transport
|
||||
this.lastWelcomeMessage = null
|
||||
|
||||
this.updateConnectionState({
|
||||
status: 'connecting',
|
||||
@@ -556,18 +524,12 @@ export class DaemonClient {
|
||||
|
||||
this.transportCleanup = [
|
||||
transport.onOpen(() => {
|
||||
this.resetConnectTimeout()
|
||||
if (this.pendingGenericTransportErrorTimeout) {
|
||||
clearTimeout(this.pendingGenericTransportErrorTimeout)
|
||||
this.pendingGenericTransportErrorTimeout = null
|
||||
}
|
||||
this.lastErrorValue = null
|
||||
this.reconnectAttempt = 0
|
||||
this.updateConnectionState({ status: 'connected' }, { event: 'TRANSPORT_OPEN' })
|
||||
this.resubscribeCheckoutDiffSubscriptions()
|
||||
this.resubscribeTerminalDirectorySubscriptions()
|
||||
this.flushPendingSendQueue()
|
||||
this.resolveConnect()
|
||||
this.sendHelloMessage()
|
||||
}),
|
||||
transport.onClose((event) => {
|
||||
this.resetConnectTimeout()
|
||||
@@ -674,6 +636,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(new Error('Daemon client closed'))
|
||||
this.rejectPendingSendQueue(new Error('Daemon client closed'))
|
||||
this.terminalStreams.clearAll()
|
||||
this.lastWelcomeMessage = null
|
||||
this.updateConnectionState(
|
||||
{ status: 'disposed' },
|
||||
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
|
||||
@@ -2599,6 +2562,45 @@ export class DaemonClient {
|
||||
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 {
|
||||
this.cleanupTransport()
|
||||
if (this.transport) {
|
||||
@@ -2660,7 +2662,7 @@ export class DaemonClient {
|
||||
|
||||
const parsed = WSOutboundMessageSchema.safeParse(parsedJson)
|
||||
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')
|
||||
return
|
||||
}
|
||||
@@ -2669,6 +2671,18 @@ export class DaemonClient {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2704,7 +2718,7 @@ export class DaemonClient {
|
||||
this.logger.info(
|
||||
{
|
||||
serverId: this.logServerId,
|
||||
clientSessionKeyHash: this.logClientSessionKeyHash,
|
||||
clientIdHash: this.logClientIdHash,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
event: metadata?.event ?? 'STATE_UPDATE',
|
||||
@@ -2745,6 +2759,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(new Error(reason ?? 'Connection lost'))
|
||||
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
|
||||
this.terminalStreams.clearAll()
|
||||
this.lastWelcomeMessage = null
|
||||
|
||||
if (wasDisposed) {
|
||||
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
|
||||
|
||||
@@ -128,26 +128,51 @@ describe("curateAgentActivity", () => {
|
||||
callId: "task-1",
|
||||
name: "Task",
|
||||
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({
|
||||
callId: "task-1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
metadata: { subAgentActivity: "Read" },
|
||||
}),
|
||||
toolCallItem({
|
||||
callId: "task-1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
metadata: { subAgentActivity: "Edit" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Investigate repository",
|
||||
log: "[Read] README.md\n[Bash] ls",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
toolName: "Bash",
|
||||
summary: "ls",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = curateAgentActivity(timeline);
|
||||
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", () => {
|
||||
|
||||
@@ -16,6 +16,63 @@ import type {
|
||||
AgentStreamEvent,
|
||||
} 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 = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -792,6 +849,207 @@ describe("AgentManager", () => {
|
||||
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 () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -238,6 +238,10 @@ type SubscriptionRecord = {
|
||||
agentId: string | null;
|
||||
};
|
||||
|
||||
type LiveEventStreamingSession = AgentSession & {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_TIMELINE_ITEMS = 2000;
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = [
|
||||
@@ -272,6 +276,16 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function supportsLiveEventStream(
|
||||
session: AgentSession
|
||||
): session is LiveEventStreamingSession {
|
||||
return (
|
||||
"streamLiveEvents" in session &&
|
||||
typeof (session as { streamLiveEvents?: unknown }).streamLiveEvents ===
|
||||
"function"
|
||||
);
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly agents = new Map<string, ActiveManagedAgent>();
|
||||
@@ -281,6 +295,7 @@ export class AgentManager {
|
||||
private readonly registry?: AgentStorage;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly liveEventPumps = new Map<string, Promise<void>>();
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -727,6 +742,7 @@ export class AgentManager {
|
||||
|
||||
// Remove the existing agent entry before swapping sessions
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
try {
|
||||
await existing.session.close();
|
||||
} catch (error) {
|
||||
@@ -756,6 +772,7 @@ export class AgentManager {
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
// Clean up previousStatus to prevent memory leak
|
||||
this.previousStatuses.delete(agentId);
|
||||
const session = agent.session;
|
||||
@@ -1275,6 +1292,7 @@ export class AgentManager {
|
||||
|
||||
let currentStatus: AgentLifecycleStatus = initialStatus;
|
||||
let hasStarted = initialBusy || hasPendingRun;
|
||||
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
||||
|
||||
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
@@ -1337,6 +1355,9 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
if (!waitForActive || hasStarted) {
|
||||
if (terminalStatusOverride) {
|
||||
currentStatus = terminalStatusOverride;
|
||||
}
|
||||
finish(null);
|
||||
}
|
||||
return;
|
||||
@@ -1348,20 +1369,15 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
if (event.event.type === "turn_failed") {
|
||||
currentStatus = "error";
|
||||
hasStarted = true;
|
||||
finish(null);
|
||||
terminalStatusOverride = "error";
|
||||
return;
|
||||
}
|
||||
if (event.event.type === "turn_completed") {
|
||||
currentStatus = "idle";
|
||||
hasStarted = true;
|
||||
finish(null);
|
||||
}
|
||||
if (event.event.type === "turn_canceled") {
|
||||
currentStatus = "idle";
|
||||
hasStarted = true;
|
||||
finish(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1461,6 +1477,7 @@ export class AgentManager {
|
||||
managed.lifecycle = "idle";
|
||||
await this.persistSnapshot(managed);
|
||||
this.emitState(managed);
|
||||
this.startLiveEventPump(managed);
|
||||
return { ...managed };
|
||||
}
|
||||
|
||||
@@ -1619,9 +1636,16 @@ export class AgentManager {
|
||||
case "turn_completed":
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
if (!agent.pendingRun && agent.lifecycle !== "idle") {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
this.emitState(agent);
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
break;
|
||||
case "turn_failed":
|
||||
if (!agent.pendingRun) {
|
||||
agent.lifecycle = "error";
|
||||
}
|
||||
agent.lastError = event.error;
|
||||
for (const [requestId] of agent.pendingPermissions) {
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
@@ -1637,6 +1661,9 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "turn_canceled":
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
}
|
||||
agent.lastError = undefined;
|
||||
for (const [requestId] of agent.pendingPermissions) {
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
@@ -1651,6 +1678,12 @@ export class AgentManager {
|
||||
}
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "turn_started":
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "running";
|
||||
this.emitState(agent);
|
||||
}
|
||||
break;
|
||||
case "permission_requested":
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.emitState(agent);
|
||||
@@ -1888,4 +1921,42 @@ export class AgentManager {
|
||||
}
|
||||
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;
|
||||
}
|
||||
| {
|
||||
type: "sub_agent";
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
log: string;
|
||||
actions: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "unknown";
|
||||
input: unknown | null;
|
||||
|
||||
+452
-11
@@ -1,4 +1,7 @@
|
||||
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 { 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(
|
||||
allowOldAssistant: Promise<void>
|
||||
): QueryMock {
|
||||
@@ -108,7 +128,8 @@ function buildFirstQueryMock(
|
||||
};
|
||||
}
|
||||
|
||||
function buildSecondQueryMock(): QueryMock {
|
||||
function buildSecondQueryMock(prompt: AsyncIterable<unknown>): QueryMock {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
@@ -126,6 +147,21 @@ function buildSecondQueryMock(): QueryMock {
|
||||
};
|
||||
}
|
||||
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;
|
||||
return {
|
||||
done: false,
|
||||
@@ -137,7 +173,7 @@ function buildSecondQueryMock(): QueryMock {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
@@ -188,22 +224,49 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
|
||||
.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", () => {
|
||||
beforeEach(() => {
|
||||
const allowOldAssistant = deferred<void>();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
const mock = buildFirstQueryMock(allowOldAssistant.promise);
|
||||
sdkMocks.firstQuery = mock;
|
||||
sdkMocks.query.mockImplementation(
|
||||
({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
const mock = buildFirstQueryMock(allowOldAssistant.promise);
|
||||
sdkMocks.firstQuery = mock;
|
||||
return mock;
|
||||
}
|
||||
const mock = buildSecondQueryMock(prompt);
|
||||
sdkMocks.secondQuery = mock;
|
||||
return mock;
|
||||
}
|
||||
const mock = buildSecondQueryMock();
|
||||
sdkMocks.secondQuery = mock;
|
||||
return mock;
|
||||
});
|
||||
);
|
||||
sdkMocks.releaseOldAssistant = () => allowOldAssistant.resolve();
|
||||
});
|
||||
|
||||
@@ -239,4 +302,382 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await firstTurn.return?.();
|
||||
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(
|
||||
"collapses sub-agent tool calls into Task metadata updates",
|
||||
"collapses sub-agent tool calls into Task sub_agent detail updates",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
@@ -1297,19 +1297,13 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const taskCalls = toolCalls.filter((item) => item.name === "Task");
|
||||
expect(taskCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Sub-agent tool calls (Read, Bash, shell, etc.) should NOT appear as
|
||||
// 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
|
||||
// We can't 100% guarantee Claude won't also use tools directly, but Task detail
|
||||
// updates should exist for the sub-agent activity
|
||||
const taskWithMetadata = taskCalls.filter(
|
||||
(item) => item.metadata?.subAgentActivity
|
||||
const taskWithSubAgentDetail = taskCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
if (taskCalls.length > 0) {
|
||||
expect(taskWithMetadata.length).toBeGreaterThanOrEqual(1);
|
||||
expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Verify the curator produces clean output with collapsed Task entries
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./claude/tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -341,12 +342,43 @@ type ToolUseCacheEntry = {
|
||||
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 MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||
|
||||
function isMetadata(value: unknown): value is AgentMetadata {
|
||||
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 {
|
||||
if (!isMetadata(value)) {
|
||||
return false;
|
||||
@@ -684,10 +716,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: 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 queryRestartNeeded = false;
|
||||
private userMessageIds: string[] = [];
|
||||
private sentUserMessageIds = new Set<string>();
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
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[]> {
|
||||
return this.availableModes;
|
||||
}
|
||||
@@ -1018,6 +1134,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
this.input?.end();
|
||||
await this.query?.interrupt?.();
|
||||
@@ -1468,6 +1585,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
const messageId = randomUUID();
|
||||
this.rememberUserMessageId(messageId);
|
||||
this.sentUserMessageIds.add(messageId);
|
||||
|
||||
return {
|
||||
type: "user",
|
||||
@@ -1504,6 +1622,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.input.push(sdkMessage);
|
||||
const expectedPromptUuid =
|
||||
typeof sdkMessage.uuid === "string" && sdkMessage.uuid.length > 0
|
||||
? sdkMessage.uuid
|
||||
: null;
|
||||
let promptActivated = expectedPromptUuid === null;
|
||||
|
||||
while (true) {
|
||||
// Check if this turn has been superseded by a new one.
|
||||
@@ -1524,6 +1647,35 @@ class ClaudeAgentSession implements AgentSession {
|
||||
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;
|
||||
if (value.type === "result") {
|
||||
break;
|
||||
@@ -1617,63 +1769,268 @@ class ClaudeAgentSession implements AgentSession {
|
||||
message: SDKMessage,
|
||||
parentToolUseId: string
|
||||
): 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 content = message.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
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"
|
||||
) {
|
||||
toolName = block.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(
|
||||
state,
|
||||
parentToolUseId
|
||||
);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
let actionUpdated = false;
|
||||
for (const action of actionCandidates) {
|
||||
if (this.appendSubAgentAction(state, action)) {
|
||||
actionUpdated = true;
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
|
||||
const prev = this.activeSidechains.get(parentToolUseId);
|
||||
if (prev === toolName) {
|
||||
return [];
|
||||
}
|
||||
this.activeSidechains.set(parentToolUseId, toolName);
|
||||
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: "Task",
|
||||
callId: parentToolUseId,
|
||||
input: null,
|
||||
output: null,
|
||||
metadata: { subAgentActivity: toolName },
|
||||
});
|
||||
if (!toolCall) {
|
||||
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 [
|
||||
{
|
||||
type: "timeline",
|
||||
item: toolCall,
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
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[] {
|
||||
const parentToolUseId = "parent_tool_use_id" in message
|
||||
? (message as { parent_tool_use_id: string | null }).parent_tool_use_id
|
||||
@@ -1983,6 +2340,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
this.toolUseCache.clear();
|
||||
this.activeSidechains.clear();
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
const historyPath = this.resolveHistoryPath(sessionId);
|
||||
@@ -2207,6 +2619,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (typeof block.tool_use_id === "string") {
|
||||
this.toolUseCache.delete(block.tool_use_id);
|
||||
this.activeSidechains.delete(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DaemonClient,
|
||||
} from "./test-utils/index.js";
|
||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { parseServerInfoStatusPayload } from "./messages.js";
|
||||
import {
|
||||
chunkPcm16,
|
||||
parsePcm16MonoWav,
|
||||
@@ -280,24 +279,17 @@ describe("daemon client E2E", () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("emits server_info on websocket connect", async () => {
|
||||
test("receives welcome on websocket connect", async () => {
|
||||
const client = new DaemonClient({
|
||||
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();
|
||||
const info = await infoPromise;
|
||||
expect(info.serverId.length).toBeGreaterThan(0);
|
||||
const welcome = client.getLastWelcomeMessage();
|
||||
expect(welcome).not.toBeNull();
|
||||
expect(welcome?.serverId.length).toBeGreaterThan(0);
|
||||
expect(typeof welcome?.resumed).toBe("boolean");
|
||||
|
||||
await client.close();
|
||||
}, 15000);
|
||||
@@ -315,38 +307,20 @@ describe("daemon client E2E", () => {
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`,
|
||||
clientId: `cid-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
|
||||
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();
|
||||
const info = await infoPromise;
|
||||
const welcome = client.getLastWelcomeMessage();
|
||||
const voice = welcome?.capabilities?.voice;
|
||||
expect(voice).toBeTruthy();
|
||||
|
||||
expect(info.dictationEnabled).toBe(false);
|
||||
expect(info.dictationReason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(info.voiceEnabled).toBe(false);
|
||||
expect(info.voiceReason).toBe("Realtime voice is disabled in daemon config.");
|
||||
expect(voice?.dictation.enabled).toBe(false);
|
||||
expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(voice?.voice.enabled).toBe(false);
|
||||
expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config.");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await isolatedCtx.cleanup();
|
||||
|
||||
@@ -32,15 +32,15 @@ class LoggingWebSocket extends OriginalWebSocket {
|
||||
const PASEO_HOME = process.env.PASEO_HOME ?? `${os.homedir()}/.paseo`;
|
||||
const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
|
||||
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
|
||||
const CLIENT_SESSION_KEY = "clsk_checkout_debug";
|
||||
const CLIENT_ID = "clsk_checkout_debug";
|
||||
|
||||
async function testMultiAgentSequence() {
|
||||
console.log("\n=== Testing multi-agent checkout sequence ===");
|
||||
console.log(`Daemon URL: ${DAEMON_URL}`);
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `${DAEMON_URL}?clientSessionKey=${CLIENT_SESSION_KEY}`,
|
||||
clientSessionKey: CLIENT_SESSION_KEY,
|
||||
url: DAEMON_URL,
|
||||
clientId: CLIENT_ID,
|
||||
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -198,13 +198,12 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const offerUrl = parseOfferUrlFromLogs(lines);
|
||||
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(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
serverId,
|
||||
role: "client",
|
||||
clientId,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -234,6 +233,8 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
let pingSent = false;
|
||||
let channelRef: Awaited<ReturnType<typeof createClientChannel>> | null = null;
|
||||
const channel = await createClientChannel(
|
||||
transport,
|
||||
daemonPublicKeyB64,
|
||||
@@ -242,8 +243,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
try {
|
||||
const payload =
|
||||
typeof data === "string" ? JSON.parse(data) : data;
|
||||
// The daemon may send an initial `server_info` status message
|
||||
// immediately upon connect; ignore everything until we see `pong`.
|
||||
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") {
|
||||
clearTimeout(timeout);
|
||||
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) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
@@ -316,9 +330,9 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
serverId,
|
||||
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 timeout = setTimeout(() => {
|
||||
@@ -346,9 +360,18 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
let pingSent = false;
|
||||
let channelRef: Awaited<ReturnType<typeof createClientChannel>> | null = null;
|
||||
const channel = await createClientChannel(transport, daemonPublicKeyB64, {
|
||||
onmessage: (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") {
|
||||
clearTimeout(timeout);
|
||||
resolve(payload);
|
||||
@@ -360,7 +383,15 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
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) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
|
||||
+28
-13
@@ -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` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
const applySnapshot = (
|
||||
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) => {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot: message.payload.agent,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
applySnapshot(message.payload.agent);
|
||||
});
|
||||
|
||||
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);
|
||||
const finalSnapshot = await secondary.fetchAgent(agent.id);
|
||||
if (finalSnapshot) {
|
||||
applySnapshot(finalSnapshot);
|
||||
}
|
||||
|
||||
// Sending while running should clear processing even if reconnect misses the
|
||||
// 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;
|
||||
}): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } {
|
||||
const updatedAt = new Date(input.snapshot.updatedAt).getTime();
|
||||
const isRunning = input.snapshot.status === "running";
|
||||
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 {
|
||||
isProcessing: input.currentIsProcessing,
|
||||
previousIsRunning: input.previousIsRunning,
|
||||
@@ -15,7 +26,6 @@ export function applyAgentInputProcessingTransition(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const isRunning = input.snapshot.status === "running";
|
||||
const wasRunning = input.previousIsRunning;
|
||||
let isProcessing = input.currentIsProcessing;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
...getFullAccessConfig("codex"),
|
||||
});
|
||||
|
||||
await primary.sendMessage(agent.id, "Run: sleep 30");
|
||||
await primary.sendMessage(agent.id, "Run: sleep 5");
|
||||
await primary.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(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` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
const applySnapshot = (
|
||||
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) => {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot: message.payload.agent,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
applySnapshot(message.payload.agent);
|
||||
});
|
||||
|
||||
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);
|
||||
const finalSnapshot = await secondary.fetchAgent(agent.id);
|
||||
if (finalSnapshot) {
|
||||
applySnapshot(finalSnapshot);
|
||||
}
|
||||
|
||||
// Sending while running should clear processing even if reconnect misses the
|
||||
// not-running -> running transition.
|
||||
|
||||
@@ -382,17 +382,7 @@ async function runUiScenario(params: {
|
||||
}
|
||||
|
||||
if (action.type === "wait_for_finish") {
|
||||
const startedAt = Date.now();
|
||||
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);
|
||||
}
|
||||
const result = await client.waitForFinish(agentId, action.timeoutMs);
|
||||
|
||||
expect(
|
||||
result.status,
|
||||
|
||||
@@ -126,4 +126,64 @@ describe("waitForFinish edge cases", () => {
|
||||
rmSync(cwd1, { recursive: true, force: true });
|
||||
rmSync(cwd2, { recursive: true, force: true });
|
||||
}, 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];
|
||||
control.open();
|
||||
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];
|
||||
expect(dataSocket).toBeDefined();
|
||||
|
||||
@@ -32,9 +32,9 @@ type RelaySocketLike = {
|
||||
};
|
||||
|
||||
type ControlMessage =
|
||||
| { type: "sync"; clientIds: string[] }
|
||||
| { type: "client_connected"; clientId: string }
|
||||
| { type: "client_disconnected"; clientId: string }
|
||||
| { type: "sync"; connectionIds: string[] }
|
||||
| { type: "connected"; connectionId: string }
|
||||
| { type: "disconnected"; connectionId: string }
|
||||
| { type: "ping" }
|
||||
| { type: "pong" };
|
||||
|
||||
@@ -50,15 +50,15 @@ function tryParseControlMessage(raw: unknown): ControlMessage | null {
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
if (parsed.type === "ping") return { type: "ping" };
|
||||
if (parsed.type === "pong") return { type: "pong" };
|
||||
if (parsed.type === "sync" && Array.isArray(parsed.clientIds)) {
|
||||
const clientIds = parsed.clientIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0);
|
||||
return { type: "sync", clientIds };
|
||||
if (parsed.type === "sync" && Array.isArray(parsed.connectionIds)) {
|
||||
const connectionIds = parsed.connectionIds.filter((id: unknown) => typeof id === "string" && id.trim().length > 0);
|
||||
return { type: "sync", connectionIds };
|
||||
}
|
||||
if (parsed.type === "client_connected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) {
|
||||
return { type: "client_connected", clientId: parsed.clientId.trim() };
|
||||
if (parsed.type === "connected" && typeof parsed.connectionId === "string" && parsed.connectionId.trim()) {
|
||||
return { type: "connected", connectionId: parsed.connectionId.trim() };
|
||||
}
|
||||
if (parsed.type === "client_disconnected" && typeof parsed.clientId === "string" && parsed.clientId.trim()) {
|
||||
return { type: "client_disconnected", clientId: parsed.clientId.trim() };
|
||||
if (parsed.type === "disconnected" && typeof parsed.connectionId === "string" && parsed.connectionId.trim()) {
|
||||
return { type: "disconnected", connectionId: parsed.connectionId.trim() };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@@ -79,7 +79,7 @@ export function startRelayTransport({
|
||||
let controlWs: WebSocket | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
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 controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controlLastSeenAt = 0;
|
||||
@@ -256,24 +256,24 @@ export function startRelayTransport({
|
||||
}
|
||||
if (msg.type === "pong") return;
|
||||
if (msg.type === "sync") {
|
||||
for (const clientId of msg.clientIds) {
|
||||
ensureClientDataSocket(clientId);
|
||||
for (const connectionId of msg.connectionIds) {
|
||||
ensureClientDataSocket(connectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === "client_connected") {
|
||||
ensureClientDataSocket(msg.clientId);
|
||||
if (msg.type === "connected") {
|
||||
ensureClientDataSocket(msg.connectionId);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "client_disconnected") {
|
||||
const existing = dataSockets.get(msg.clientId);
|
||||
if (msg.type === "disconnected") {
|
||||
const existing = dataSockets.get(msg.connectionId);
|
||||
if (existing) {
|
||||
try {
|
||||
existing.close(1001, "Client disconnected");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
dataSockets.delete(msg.clientId);
|
||||
dataSockets.delete(msg.connectionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -291,25 +291,25 @@ export function startRelayTransport({
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const ensureClientDataSocket = (clientId: string): void => {
|
||||
const ensureClientDataSocket = (routingId: string): void => {
|
||||
if (stopped) return;
|
||||
if (!clientId) return;
|
||||
if (dataSockets.has(clientId)) return;
|
||||
if (!routingId) return;
|
||||
if (dataSockets.has(routingId)) return;
|
||||
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
serverId,
|
||||
role: "server",
|
||||
clientId,
|
||||
connectionId: routingId,
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
dataSockets.set(clientId, socket);
|
||||
dataSockets.set(routingId, socket);
|
||||
|
||||
let attached = false;
|
||||
const openTimeout = setTimeout(() => {
|
||||
if (stopped) 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 {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
@@ -319,18 +319,17 @@ export function startRelayTransport({
|
||||
|
||||
socket.on("open", () => {
|
||||
clearTimeout(openTimeout);
|
||||
relayLogger.info({ url, clientId }, "relay_data_connected");
|
||||
relayLogger.info({ url, routingId }, "relay_data_connected");
|
||||
if (attached) return;
|
||||
attached = true;
|
||||
const externalMetadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: `session:${clientId}`,
|
||||
};
|
||||
if (daemonKeyPair) {
|
||||
void attachEncryptedSocket(
|
||||
socket,
|
||||
daemonKeyPair,
|
||||
relayLogger.child({ clientId }),
|
||||
relayLogger.child({ routingId }),
|
||||
attachSocket,
|
||||
externalMetadata
|
||||
);
|
||||
@@ -342,16 +341,16 @@ export function startRelayTransport({
|
||||
socket.on("close", (code, reason) => {
|
||||
clearTimeout(openTimeout);
|
||||
relayLogger.warn(
|
||||
{ code, reason: reason?.toString?.(), url, clientId },
|
||||
{ code, reason: reason?.toString?.(), url, routingId },
|
||||
"relay_data_disconnected"
|
||||
);
|
||||
if (dataSockets.get(clientId) === socket) {
|
||||
dataSockets.delete(clientId);
|
||||
if (dataSockets.get(routingId) === socket) {
|
||||
dataSockets.delete(routingId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
relayLogger.warn({ err, url, clientId }, "relay_data_error");
|
||||
relayLogger.warn({ err, url, routingId }, "relay_data_error");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -5421,16 +5421,29 @@ export class Session {
|
||||
}, effectiveTimeoutMs)
|
||||
|
||||
try {
|
||||
const result = await this.agentManager.waitForAgentEvent(agentId, {
|
||||
let result = await this.agentManager.waitForAgentEvent(agentId, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
const final = await this.getAgentPayloadById(agentId)
|
||||
let final = await this.getAgentPayloadById(agentId)
|
||||
if (!final) {
|
||||
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({
|
||||
type: 'wait_for_finish_response',
|
||||
|
||||
@@ -11,24 +11,24 @@ import {
|
||||
|
||||
export type DaemonClientConfig = Omit<
|
||||
SharedDaemonClientConfig,
|
||||
"webSocketFactory" | "transportFactory"
|
||||
"webSocketFactory" | "transportFactory" | "clientId"
|
||||
>;
|
||||
export type CreateAgentOptions = CreateAgentRequestOptions;
|
||||
export { type SendMessageOptions, type DaemonEvent, type DaemonEventHandler };
|
||||
|
||||
let testClientCounter = 0;
|
||||
|
||||
function nextTestClientSessionKey(): string {
|
||||
function nextTestClientId(): string {
|
||||
testClientCounter += 1;
|
||||
return `clsk_test_client_${testClientCounter}`;
|
||||
return `clid_test_client_${testClientCounter}`;
|
||||
}
|
||||
|
||||
export class DaemonClient extends SharedDaemonClient {
|
||||
constructor(config: DaemonClientConfig) {
|
||||
const clientSessionKey = config.clientSessionKey ?? nextTestClientSessionKey();
|
||||
const clientId = nextTestClientId();
|
||||
super({
|
||||
...config,
|
||||
clientSessionKey,
|
||||
clientId,
|
||||
webSocketFactory: (url, options) =>
|
||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ vi.mock("./push/push-service.js", () => ({
|
||||
|
||||
import {
|
||||
VoiceAssistantWebSocketServer,
|
||||
type ExternalSocketMetadata,
|
||||
} from "./websocket-server";
|
||||
import { parseServerInfoStatusPayload } from "./messages.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", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-1",
|
||||
};
|
||||
const clientId = "cid-relay-reconnect";
|
||||
|
||||
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);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -269,7 +329,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
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);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
@@ -278,19 +343,8 @@ describe("relay external socket reconnect behavior", () => {
|
||||
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 request = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
let closeCode: number | null = null;
|
||||
@@ -300,63 +354,105 @@ describe("relay external socket reconnect behavior", () => {
|
||||
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(closeReason).toBe("Missing clientSessionKey");
|
||||
expect(closeCode).toBe(4001);
|
||||
expect(closeReason).toBe("Hello timeout");
|
||||
expect(sessionMock.instances).toHaveLength(0);
|
||||
|
||||
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 metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-multi",
|
||||
};
|
||||
const clientId = "cid-resume-flag";
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await server.attachExternalSocket(socket1, metadata);
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
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" },
|
||||
const firstSocket = new MockSocket();
|
||||
const firstWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: firstSocket,
|
||||
clientId,
|
||||
});
|
||||
expect(firstWelcome.resumed).toBe(false);
|
||||
|
||||
expect(socket1.sent.length).toBeGreaterThan(1);
|
||||
expect(socket2.sent.length).toBeGreaterThan(1);
|
||||
firstSocket.emit("close", 1006, "");
|
||||
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();
|
||||
});
|
||||
|
||||
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 request = {
|
||||
headers: {
|
||||
host: "localhost:6767",
|
||||
origin: "http://localhost:6767",
|
||||
"user-agent": "vitest",
|
||||
},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
url: "/ws?clientSessionKey=clsk_direct_reconnect",
|
||||
};
|
||||
|
||||
const firstSocket = new MockSocket();
|
||||
const firstWelcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket: firstSocket,
|
||||
clientId: "cid-new-1",
|
||||
});
|
||||
expect(firstWelcome.resumed).toBe(false);
|
||||
|
||||
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();
|
||||
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);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -365,7 +461,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
expect(session.cleanup).not.toHaveBeenCalled();
|
||||
|
||||
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);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
@@ -374,32 +475,25 @@ describe("relay external socket reconnect behavior", () => {
|
||||
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 clientSessionKey = "clsk_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 clientId = "cid-switch-path";
|
||||
|
||||
const directSocket = new MockSocket();
|
||||
await (server as any).attachSocket(directSocket, directRequest);
|
||||
await attachDirectAndHello({
|
||||
server,
|
||||
socket: directSocket,
|
||||
clientId,
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
const relaySocket = new MockSocket();
|
||||
await server.attachExternalSocket(relaySocket, relayMetadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket: relaySocket,
|
||||
clientId,
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
|
||||
const onMessage = session.args.onMessage as
|
||||
@@ -411,8 +505,8 @@ describe("relay external socket reconnect behavior", () => {
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
expect(directSocket.sent.length).toBeGreaterThan(1);
|
||||
expect(relaySocket.sent.length).toBeGreaterThan(1);
|
||||
expect(directSocket.sent.length).toBeGreaterThan(0);
|
||||
expect(relaySocket.sent.length).toBeGreaterThan(0);
|
||||
|
||||
directSocket.emit("close", 1006, "");
|
||||
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 () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-2",
|
||||
};
|
||||
const clientId = "cid-relay-grace-expire";
|
||||
|
||||
const socket1 = new MockSocket();
|
||||
await server.attachExternalSocket(socket1, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket: socket1,
|
||||
clientId,
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
@@ -444,53 +539,46 @@ describe("relay external socket reconnect behavior", () => {
|
||||
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 server = createServer({ speechReadiness });
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-server-info-capabilities",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
expect(typeof socket.sent[0]).toBe("string");
|
||||
const envelope = JSON.parse(socket.sent[0] as string) as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: unknown;
|
||||
const welcome = await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-server-info-capabilities",
|
||||
}) as {
|
||||
version?: unknown;
|
||||
capabilities?: {
|
||||
voice?: {
|
||||
dictation?: { enabled?: unknown; reason?: unknown };
|
||||
voice?: { enabled?: unknown; reason?: unknown };
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(envelope.type).toBe("session");
|
||||
expect(envelope.message?.type).toBe("status");
|
||||
|
||||
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(
|
||||
expect(welcome.version).toBe(TEST_DAEMON_VERSION);
|
||||
expect(welcome.capabilities?.voice?.dictation?.enabled).toBe(
|
||||
speechReadiness.dictation.enabled
|
||||
);
|
||||
expect(payload?.capabilities?.voice?.dictation.reason).toBe("");
|
||||
expect(payload?.capabilities?.voice?.voice.enabled).toBe(
|
||||
expect(welcome.capabilities?.voice?.dictation?.reason).toBe("");
|
||||
expect(welcome.capabilities?.voice?.voice?.enabled).toBe(
|
||||
speechReadiness.realtimeVoice.enabled
|
||||
);
|
||||
expect(payload?.capabilities?.voice?.voice.reason).toBe("");
|
||||
expect(welcome.capabilities?.voice?.voice?.reason).toBe("");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("broadcasts updated server_info when capabilities change", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-server-info-broadcast",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-server-info-broadcast",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
|
||||
const speechReadiness = createReadySpeechReadinessSnapshot();
|
||||
@@ -513,12 +601,12 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("includes temporary retry guidance while models are downloading", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-server-info-download-guidance",
|
||||
};
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-server-info-download-guidance",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
|
||||
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
||||
@@ -542,13 +630,13 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-binary-inbound",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-binary-inbound",
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
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 () => {
|
||||
const server = createServer();
|
||||
const metadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: "session:client-binary-outbound",
|
||||
};
|
||||
|
||||
const socket = new MockSocket();
|
||||
await server.attachExternalSocket(socket, metadata);
|
||||
await attachRelayAndHello({
|
||||
server,
|
||||
socket,
|
||||
clientId: "cid-binary-outbound",
|
||||
});
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
type ServerInfoStatusPayload,
|
||||
type WSHelloMessage,
|
||||
type WSWelcomeMessage,
|
||||
WSInboundMessageSchema,
|
||||
type ServerCapabilityState,
|
||||
type ServerCapabilities,
|
||||
@@ -51,7 +53,11 @@ import {
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
export type ExternalSocketMetadata = {
|
||||
transport: "relay";
|
||||
externalSessionKey: string;
|
||||
};
|
||||
|
||||
type PendingConnection = {
|
||||
connectionLogger: pino.Logger;
|
||||
helloTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type WebSocketServerConfig = {
|
||||
@@ -152,11 +158,15 @@ type SessionConnection = {
|
||||
clientId: string;
|
||||
connectionLogger: pino.Logger;
|
||||
sockets: Set<WebSocketLike>;
|
||||
externalSessionKey: string | null;
|
||||
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
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 {
|
||||
constructor() {
|
||||
@@ -171,9 +181,9 @@ export class MissingDaemonVersionError extends Error {
|
||||
export class VoiceAssistantWebSocketServer {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly wss: WebSocketServer;
|
||||
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
|
||||
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
|
||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||
private clientIdCounter = 0;
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly agentManager: AgentManager;
|
||||
@@ -334,7 +344,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
return;
|
||||
}
|
||||
this.serverCapabilities = next;
|
||||
this.broadcastServerInfo();
|
||||
this.broadcastCapabilitiesUpdate();
|
||||
}
|
||||
|
||||
public async attachExternalSocket(
|
||||
@@ -350,6 +360,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
...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>[] = [];
|
||||
for (const connection of uniqueConnections) {
|
||||
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);
|
||||
this.pendingConnections.clear();
|
||||
this.sessions.clear();
|
||||
this.externalSessionsByKey.clear();
|
||||
this.wss.close();
|
||||
@@ -416,61 +449,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
metadata?: ExternalSocketMetadata
|
||||
): Promise<void> {
|
||||
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> = {
|
||||
clientId,
|
||||
transport: metadata?.transport === "relay" ? "relay" : "direct",
|
||||
};
|
||||
if (requestMetadata.host) {
|
||||
@@ -486,6 +465,47 @@ export class VoiceAssistantWebSocketServer {
|
||||
connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
|
||||
}
|
||||
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;
|
||||
|
||||
const session = new Session({
|
||||
@@ -538,23 +558,109 @@ export class VoiceAssistantWebSocketServer {
|
||||
clientId,
|
||||
connectionLogger,
|
||||
sockets: new Set([ws]),
|
||||
externalSessionKey,
|
||||
externalDisconnectCleanupTimeout: null,
|
||||
};
|
||||
return connection;
|
||||
}
|
||||
|
||||
this.sessions.set(ws, connection);
|
||||
if (externalSessionKey) {
|
||||
this.externalSessionsByKey.set(externalSessionKey, connection);
|
||||
private clearPendingConnection(ws: WebSocketLike): PendingConnection | null {
|
||||
const pending = this.pendingConnections.get(ws);
|
||||
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(
|
||||
{ clientId, externalSessionKey, totalSessions: this.sessions.size },
|
||||
"Client connected"
|
||||
this.clearPendingConnection(ws);
|
||||
const existing = this.externalSessionsByKey.get(clientId);
|
||||
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 {
|
||||
@@ -567,7 +673,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
};
|
||||
}
|
||||
|
||||
private broadcastServerInfo(): void {
|
||||
private broadcastCapabilitiesUpdate(): void {
|
||||
this.broadcast(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
@@ -576,27 +682,13 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
private sendServerInfo(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 {
|
||||
private bindSocketHandlers(ws: WebSocketLike): void {
|
||||
ws.on("message", (data) => {
|
||||
void this.handleRawMessage(ws, data);
|
||||
});
|
||||
|
||||
ws.on("close", async (code: number, reason: unknown) => {
|
||||
await this.detachSocket(ws, connection, {
|
||||
await this.detachSocket(ws, {
|
||||
code: typeof code === "number" ? code : undefined,
|
||||
reason,
|
||||
});
|
||||
@@ -604,8 +696,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
ws.on("error", async (error) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
connection.connectionLogger.error({ err }, "Client error");
|
||||
await this.detachSocket(ws, connection, { error: err });
|
||||
const active = this.sessions.get(ws);
|
||||
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(
|
||||
ws: WebSocketLike,
|
||||
connection: SessionConnection,
|
||||
details: {
|
||||
code?: number;
|
||||
reason?: unknown;
|
||||
error?: Error;
|
||||
}
|
||||
): Promise<void> {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
if (activeConnection !== connection) return;
|
||||
const pending = this.clearPendingConnection(ws);
|
||||
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);
|
||||
connection.sockets.delete(ws);
|
||||
|
||||
if (connection.externalSessionKey && connection.sockets.size === 0) {
|
||||
if (connection.sockets.size === 0) {
|
||||
if (connection.externalDisconnectCleanupTimeout) {
|
||||
clearTimeout(connection.externalDisconnectCleanupTimeout);
|
||||
}
|
||||
@@ -651,7 +760,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
connection.connectionLogger.trace(
|
||||
{
|
||||
clientId: connection.clientId,
|
||||
externalSessionKey: connection.externalSessionKey,
|
||||
code: details.code,
|
||||
reason: stringifyCloseReason(details.reason),
|
||||
reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS,
|
||||
@@ -690,11 +798,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.sessions.delete(socket);
|
||||
}
|
||||
connection.sockets.clear();
|
||||
if (connection.externalSessionKey) {
|
||||
const existing = this.externalSessionsByKey.get(connection.externalSessionKey);
|
||||
if (existing === connection) {
|
||||
this.externalSessionsByKey.delete(connection.externalSessionKey);
|
||||
}
|
||||
const existing = this.externalSessionsByKey.get(connection.clientId);
|
||||
if (existing === connection) {
|
||||
this.externalSessionsByKey.delete(connection.clientId);
|
||||
}
|
||||
|
||||
connection.connectionLogger.trace(
|
||||
@@ -708,15 +814,24 @@ export class VoiceAssistantWebSocketServer {
|
||||
ws: WebSocketLike,
|
||||
data: Buffer | ArrayBuffer | Buffer[] | string
|
||||
): Promise<void> {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
const pendingConnection = this.pendingConnections.get(ws);
|
||||
const log = activeConnection?.connectionLogger ?? pendingConnection?.connectionLogger ?? this.logger;
|
||||
|
||||
try {
|
||||
const activeConnection = this.sessions.get(ws);
|
||||
const buffer = bufferFromWsData(data);
|
||||
const asBytes = asUint8Array(buffer);
|
||||
if (asBytes) {
|
||||
const frame = decodeBinaryMuxFrame(asBytes);
|
||||
if (frame) {
|
||||
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;
|
||||
}
|
||||
activeConnection.session.handleBinaryFrame(frame);
|
||||
@@ -726,6 +841,22 @@ export class VoiceAssistantWebSocketServer {
|
||||
const parsed = JSON.parse(buffer.toString());
|
||||
const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
|
||||
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 isUnknownSchema =
|
||||
requestInfo?.requestId != null &&
|
||||
@@ -734,7 +865,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
"type" in parsed &&
|
||||
(parsed as { type?: unknown }).type === "session";
|
||||
|
||||
const log = activeConnection?.connectionLogger ?? this.logger;
|
||||
log.warn(
|
||||
{
|
||||
clientId: activeConnection?.clientId,
|
||||
@@ -786,8 +916,43 @@ export class VoiceAssistantWebSocketServer {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -816,7 +981,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
? `${rawPayload.slice(0, 2000)}... (truncated)`
|
||||
: rawPayload;
|
||||
|
||||
this.logger.error(
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
rawPayload: trimmedRawPayload,
|
||||
@@ -825,6 +990,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
"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);
|
||||
if (requestInfo) {
|
||||
this.sendToClient(
|
||||
@@ -952,7 +1127,6 @@ type SocketRequestMetadata = {
|
||||
origin?: string;
|
||||
userAgent?: string;
|
||||
remoteAddress?: string;
|
||||
clientSessionKey?: string;
|
||||
};
|
||||
|
||||
function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
@@ -983,30 +1157,12 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
|
||||
typeof record.socket?.remoteAddress === "string"
|
||||
? record.socket.remoteAddress
|
||||
: 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 {
|
||||
...(host ? { host } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(remoteAddress ? { remoteAddress } : {}),
|
||||
...(clientSessionKey ? { clientSessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,24 @@ describe("relay websocket URL versioning", () => {
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
clientSessionKey: "clsk_test",
|
||||
})
|
||||
);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -83,28 +83,23 @@ function shouldUseSecureWebSocket(port: number): boolean {
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(
|
||||
endpoint: string,
|
||||
params?: { clientSessionKey?: string }
|
||||
endpoint: string
|
||||
): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
if (
|
||||
typeof params?.clientSessionKey === "string" &&
|
||||
params.clientSessionKey.trim().length > 0
|
||||
) {
|
||||
url.searchParams.set("clientSessionKey", params.clientSessionKey.trim());
|
||||
}
|
||||
return url.toString();
|
||||
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
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;
|
||||
}): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
@@ -114,16 +109,8 @@ export function buildRelayWebSocketUrl(params: {
|
||||
url.searchParams.set("serverId", params.serverId);
|
||||
url.searchParams.set("role", params.role);
|
||||
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
|
||||
if (
|
||||
params.clientId &&
|
||||
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);
|
||||
if (params.connectionId) {
|
||||
url.searchParams.set("connectionId", params.connectionId);
|
||||
}
|
||||
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', () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: 'initialize_agent_request',
|
||||
|
||||
@@ -109,4 +109,41 @@ describe("shared messages tool_call schema", () => {
|
||||
expect(missingDetail.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({
|
||||
name: "task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
subAgentActivity: "Running tests",
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md",
|
||||
actions: [
|
||||
{
|
||||
index: 1,
|
||||
toolName: "Read",
|
||||
summary: "README.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Task",
|
||||
summary: "Running tests",
|
||||
displayName: "Explore",
|
||||
summary: "Inspect repository structure",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
|
||||
displayName = "Worktree Setup";
|
||||
summary = input.detail.branchName;
|
||||
break;
|
||||
case "sub_agent":
|
||||
displayName = readString(input.detail.subAgentType) ?? "Task";
|
||||
summary = readString(input.detail.description);
|
||||
break;
|
||||
case "unknown":
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user