fix(client): keep daemon connections lean by default (#4470)

Move supported protocol capabilities into client defaults and make session traffic subscription-driven. Preserve timeline readiness and reconnect recovery across SDK, CLI, and plugin consumers.
This commit is contained in:
Mohamed Boudra
2026-09-08 12:59:25 +02:00
committed by GitHub
parent 158525624f
commit 18aaec2772
31 changed files with 1012 additions and 68 deletions
@@ -20,6 +20,7 @@ const INBOUND_PERMISSION = {
"agent.skills.uninstall.request": "daemon.manage",
"agent.timeline.list_prompts.request": "workspace.read",
"agent.timeline.append.request": "workspace.write",
"session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"],
"agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"],
agent_permission_response: "workspace.write",
archive_agent_request: ["workspace.write", "hub.execute"],
@@ -220,6 +221,7 @@ const OUTBOUND_PERMISSION = {
"agent.timeline.list_prompts.response": "workspace.read",
"agent.timeline.append.response": "workspace.write",
"agent.timeline.replacement": ["workspace.read", "hub.execute"],
"session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"],
"agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"],
agent_archived: ["workspace.read", "hub.execute"],
agent_attention_required: ["workspace.read", "hub.execute"],
@@ -84,14 +84,11 @@ describe("client activity tracking", () => {
reject(new Error(`Timeout waiting for attention_required (${timeout}ms)`));
}, timeout);
const cleanup = client.on("agent_stream", (msg) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.agentId !== agentId) return;
if (msg.payload.event.type !== "attention_required") return;
const cleanup = client.onAgentAttentionRequired((notification) => {
if (notification.agentId !== agentId) return;
clearTimeout(timer);
cleanup();
resolve(msg.payload.event);
resolve({ type: "attention_required", ...notification });
});
});
}
@@ -107,7 +104,7 @@ describe("client activity tracking", () => {
reject(new Error(`Timeout waiting for assistant timeline (${timeout}ms)`));
}, timeout);
const cleanup = client.on("agent_stream", (msg) => {
const cleanup = client.subscribeAgentTimeline(agentId, (msg) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.agentId !== agentId) return;
if (msg.payload.event.type !== "timeline") return;
@@ -1473,7 +1473,7 @@ test("creates agent and exercises lifecycle", async () => {
sawAssistantMessage = true;
}
});
const unsubscribeRawStream = ctx.client.on("agent_stream", (message) => {
const unsubscribeRawStream = ctx.client.subscribeAgentTimeline(agent.id, (message) => {
if (message.type !== "agent_stream") {
return;
}
@@ -61,7 +61,7 @@ function tmpCwd(provider: ContractProvider): string {
function collectUserMessageEvents(client: DaemonClient, agentId: string): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
client.on("agent_stream", (message) => {
client.subscribeAgentTimeline(agentId, (message) => {
if (message.type !== "agent_stream" || message.payload.agentId !== agentId) {
return;
}
@@ -0,0 +1,91 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { createPaseoApi } from "@getpaseo/client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("an RPC-only plugin receives no agent, project or provider data", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-quiet-plugin-"));
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "quiet" }));
await writeFile(
path.join(directory, "index.server.ts"),
`
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export default function contribute(server) {
const counts = {};
const observe = message => {
if (message.type !== "paseo_frame" || typeof message.data !== "string") return;
const frame = JSON.parse(message.data);
const type = frame.message?.type;
if (type) counts[type] = (counts[type] ?? 0) + 1;
};
process.on("message", observe);
server.handle(defineRpc({name:"counts", input:z.object({}), output:z.record(z.string(),z.number())}), () => counts);
return () => process.off("message", observe);
}`,
);
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const idle = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const legacy = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
capabilities: {
selective_agent_timeline: false,
explicit_event_subscriptions: false,
provider_snapshot_references: false,
},
});
const legacyTypes: string[] = [];
legacy.subscribeRawMessages((message) => legacyTypes.push(message.type));
const idleTypes: string[] = [];
idle.subscribeRawMessages((message) => idleTypes.push(message.type));
try {
await client.connect();
await idle.connect();
await legacy.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const agent = await client.createAgent({ provider: "claude", cwd: directory });
const api = createPaseoApi(client);
const received: string[] = [];
const release = api.agents
.ref(agent.id)
.timeline.subscribe((event) => received.push(event.event.type));
await release.ready;
await daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
id: "row",
text: "only the subscriber sees this",
});
await expect.poll(() => received).toEqual(["timeline"]);
await client.sendMessage(agent.id, "Say hello");
await client.waitForFinish(agent.id);
await client.refreshProvidersSnapshot({ cwd: directory });
const counts = await client.invokePluginRpc("quiet", "counts", {});
expect(counts).toEqual({ status: 1 });
await legacy.getDaemonConfig();
expect(legacyTypes).toEqual(
expect.arrayContaining(["project.update", "providers_snapshot_update", "agent_stream"]),
);
expect(idleTypes.filter((type) => type !== "status")).toEqual([]);
const deliveredBeforeRelease = received.length;
release();
await client.getDaemonConfig();
await daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
id: "row-2",
text: "unsubscribed",
});
await client.getDaemonConfig();
expect(received).toHaveLength(deliveredBeforeRelease);
} finally {
await legacy.close();
await idle.close();
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
+56 -9
View File
@@ -1,3 +1,4 @@
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { AgentRequests } from "./agent/requests/index.js";
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
@@ -715,6 +716,8 @@ export class Session {
unsubscribe: () => void;
} | null = null;
private projectSyncEnabled = false;
private readonly defaultEventSubscriptionSource = {};
private readonly eventSubscriptions = new Map<object, Set<SessionEventSubscription>>();
private readonly workspaceUpdateTails = new Map<string, Promise<void>>();
private clientActivity: {
deviceType: "web" | "mobile";
@@ -925,6 +928,7 @@ export class Session {
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons),
supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots),
wantsSnapshotChanges: () => this.wantsEvent("providers_snapshot_update"),
supportsProviderSnapshotReferences: () =>
this.supports(CLIENT_CAPS.providerSnapshotReferences),
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
@@ -1135,6 +1139,7 @@ export class Session {
updateClientCapabilities(capabilities: Record<string, unknown> | null, source?: object): void {
this.clientCapabilities = parseClientCapabilities(capabilities);
if (source) {
this.eventSubscriptions.delete(source);
this.clientCapabilitiesBySource.set(source, this.clientCapabilities);
}
if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
@@ -1145,6 +1150,7 @@ export class Session {
clearAgentTimelineSubscription(source: object): void {
this.clientCapabilitiesBySource.delete(source);
this.eventSubscriptions.delete(source);
if (this.viewedTimelineAgentIdsBySource.delete(source)) {
this.rebuildViewedTimelineAgentIds();
}
@@ -1223,6 +1229,7 @@ export class Session {
continue;
const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline);
if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") {
if (!this.wantsEvent("agent_attention_required", source)) continue;
this.onMessageToSource(source, {
type: "agent_attention_required",
payload: {
@@ -1267,6 +1274,7 @@ export class Session {
}
private async publishProjectUpdate(update: ProjectUpdate): Promise<void> {
if (!this.wantsEvent("project.update")) return;
const projectedPayload =
update.kind === "upsert"
? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) }
@@ -1275,15 +1283,7 @@ export class Session {
type: "project.update",
payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled),
};
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message);
return;
}
for (const [source, capabilities] of this.clientCapabilitiesBySource) {
if (capabilities.has(CLIENT_CAPS.projectUpdates)) {
this.onMessageToSource(source, message);
}
}
this.emit(message);
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
@@ -2333,6 +2333,20 @@ export class Session {
return this.handleProviderSubagentListRequest(msg);
case "agent.provider_subagents.timeline.get.request":
return this.handleProviderSubagentTimelineRequest(msg, source);
case "session.events.set_subscription.request": {
this.eventSubscriptions.set(
source ?? this.defaultEventSubscriptionSource,
new Set(msg.events),
);
this.emitForSource(
{
type: "session.events.set_subscription.response",
payload: { requestId: msg.requestId },
},
source,
);
return undefined;
}
case "agent.timeline.set_subscription.request": {
const agentIds = [...new Set(msg.agentIds)].sort();
if (
@@ -7703,10 +7717,43 @@ export class Session {
/**
* Emit a message to the client
*/
// COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08.
private wantsEvent(event: SessionEventSubscription, source?: object): boolean {
if (!source && this.clientCapabilitiesBySource.size > 0) {
return [...this.clientCapabilitiesBySource.keys()].some((candidate) =>
this.wantsEvent(event, candidate),
);
}
const capabilities = source
? this.clientCapabilitiesBySource.get(source)!
: this.clientCapabilities;
if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false;
return (
!capabilities.has(CLIENT_CAPS.explicitEventSubscriptions) ||
this.eventSubscriptions.get(source ?? this.defaultEventSubscriptionSource)?.has(event) ===
true
);
}
private emit(msg: SessionOutboundMessage): void {
if (!this.authorization.allowsOutbound(msg)) {
return;
}
if (
msg.type === "project.update" ||
msg.type === "providers_snapshot_update" ||
msg.type === "agent_attention_required" ||
msg.type === "agent_permission_request" ||
msg.type === "agent_permission_resolved"
) {
if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) {
for (const source of this.clientCapabilitiesBySource.keys()) {
if (this.wantsEvent(msg.type, source)) this.onMessageToSource(source, msg);
}
return;
}
if (!this.wantsEvent(msg.type)) return;
}
// JSON.stringify(msg) is only computed when trace is enabled — it runs for
// every outbound message otherwise, and trace is disabled by default.
// Optional-chained because test logger stubs don't implement isLevelEnabled.
@@ -681,3 +681,13 @@ describe("subscription lifecycle", () => {
expect(h.agentUpdates()).toEqual([{ kind: "remove", agentId: "a" }]);
});
});
test("an idle session skips agent hydration while preserving workspace updates", async () => {
const h = buildHarness();
const agent = { ...h.managed("unobserved"), workspaceId: "workspace" };
// No payload is registered: attempting to hydrate this agent would fail.
await h.service.forwardLiveAgent(agent);
expect(h.loggedErrors).toEqual([]);
expect(h.agentUpdates()).toEqual([]);
expect(h.workspaceUpdates).toEqual([agent.workspaceId]);
});
@@ -390,6 +390,18 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
}
function forwardLiveAgent(agent: ManagedAgent): Promise<void> {
if (!subscription) {
const workspaceId = agent.workspaceId;
return workspaceId
? enqueueAgentUpdate(agent.id, async () => {
try {
await deps.emitWorkspaceUpdateForWorkspaceId(workspaceId);
} catch (error) {
deps.logger.error({ err: error }, "Failed to emit workspace update");
}
})
: Promise.resolve();
}
const payload = toAgentPayload(agent);
return enqueueAgentUpdate(payload.id, () => emitLiveAgentUpdate(payload));
}
@@ -54,6 +54,7 @@ function makeSubsystem(options: MakeOptions = {}) {
isProviderVisibleToClient: (provider) => visible.has(provider),
supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false,
supportsProviderSnapshotReferences: () => false,
wantsSnapshotChanges: () => true,
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
@@ -395,6 +396,7 @@ it("announces shared content without retransmitting models or hashing discovery
isProviderVisibleToClient: () => true,
supportsCustomModeIcons: () => true,
supportsCompactProviderSnapshots: () => true,
wantsSnapshotChanges: () => true,
supportsProviderSnapshotReferences: () => references,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
@@ -48,6 +48,7 @@ export interface ProviderCatalogSessionHost {
supportsCustomModeIcons(): boolean;
supportsCompactProviderSnapshots(): boolean;
supportsProviderSnapshotReferences(): boolean;
wantsSnapshotChanges(): boolean;
listProviderAvailability(): Promise<ProviderAvailability[]>;
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
}
@@ -82,6 +83,7 @@ export class ProviderCatalogSession {
start(): void {
const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => {
if (!this.host.wantsSnapshotChanges()) return;
const previous = this.visibleSnapshot(transition.previous);
const current = this.visibleSnapshot(transition.current);
if (sameSnapshotRecords(previous.records, current.records)) return;
@@ -32,7 +32,7 @@ function makeErrorHandler(reject: (error: Error) => void) {
}
function makeSpeakToolHandler(resolve: (value: string) => void) {
return (msg: SessionMessage<"agent_stream">) => {
return (msg: SessionMessage<"agent_stream" | "agent.timeline.replacement">) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.event.type !== "timeline") return;
const item = msg.payload.event.item;
@@ -131,7 +131,10 @@ function waitForSignal<T>(
});
const speakToolPromise = waitForSignal<string>(120000, (resolve, reject) => {
const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve));
const offStream = ctx.client.subscribeAgentTimeline(
targetAgent.id,
makeSpeakToolHandler(resolve),
);
const offError = ctx.client.on("activity_log", makeErrorHandler(reject));
return () => {
offStream();
@@ -225,7 +225,7 @@ for (const targetProvider of [
const timelineToolAgentIds = new Set<string>();
const activityErrors: string[] = [];
const offStream = ctx.client.on("agent_stream", (message) => {
const offStream = ctx.client.subscribeAgentTimeline(voiceAgentId, (message) => {
if (message.type !== "agent_stream") {
return;
}
@@ -1755,6 +1755,7 @@ export class VoiceAssistantWebSocketServer {
forgeProviders: true,
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
selectiveAgentTimeline: true,
explicitEventSubscriptions: true,
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
canonicalSubmittedPrompts: true,
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.