feat(sync): keep live data scoped and current

Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates.
This commit is contained in:
Mohamed Boudra
2026-07-17 11:12:45 +02:00
parent bce2c50b9e
commit 95f68ef829
37 changed files with 3762 additions and 814 deletions
+92 -1
View File
@@ -76,7 +76,7 @@ function createMockTransport() {
return {
transport,
sent,
triggerOpen: (options?: { preserveSent?: boolean }) => {
triggerOpen: (options?: { preserveSent?: boolean; features?: Record<string, boolean> }) => {
onOpen();
if (!options?.preserveSent) {
// Ignore HELLO handshake payloads in assertions.
@@ -92,6 +92,7 @@ function createMockTransport() {
serverId: `srv_test_${serverInfoOrdinal++}`,
hostname: null,
version: null,
...(options?.features ? { features: options.features } : {}),
},
},
}),
@@ -219,6 +220,95 @@ test("advertises consumer-provided browser automation capabilities", async () =>
});
});
test("sets the complete viewed timeline subscription only when the daemon supports it", async () => {
const supportedTransport = createMockTransport();
const supportedClient = new DaemonClient({
url: "ws://test",
clientId: "timeline_supported",
transportFactory: () => supportedTransport.transport,
reconnect: { enabled: false },
});
const legacyTransport = createMockTransport();
const legacyClient = new DaemonClient({
url: "ws://test",
clientId: "timeline_legacy",
transportFactory: () => legacyTransport.transport,
reconnect: { enabled: false },
});
clients.push(supportedClient, legacyClient);
const supportedConnect = supportedClient.connect();
supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } });
await supportedConnect;
const legacyConnect = legacyClient.connect();
legacyTransport.triggerOpen();
await legacyConnect;
expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({
selectiveAgentTimeline: true,
});
const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]);
await Promise.resolve();
const request = parseSentFrame(supportedTransport.sent[0]);
supportedTransport.triggerMessage(
wrapSessionMessage({
type: "agent.timeline.set_subscription.response",
payload: {
requestId: request.requestId,
agentIds: ["agent-a", "agent-b"],
},
}),
);
await setPromise;
await legacyClient.setAgentTimelineSubscription(["agent-a"]);
expect({ request, legacyFrames: legacyTransport.sent }).toEqual({
request: {
type: "agent.timeline.set_subscription.request",
requestId: expect.any(String),
agentIds: ["agent-a", "agent-b"],
},
legacyFrames: [],
});
});
test("normalizes legacy and dedicated agent attention notifications", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "attention_normalization",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connect = client.connect();
mock.triggerOpen();
await connect;
const notifications: unknown[] = [];
client.onAgentAttentionRequired((notification) => notifications.push(notification));
const payload = {
agentId: "agent-a",
reason: "finished",
timestamp: "2026-07-12T00:00:00.000Z",
shouldNotify: true,
} as const;
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: payload.agentId,
timestamp: payload.timestamp,
event: { type: "attention_required", provider: "codex", ...payload },
},
}),
);
mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload }));
expect(notifications).toEqual([payload, payload]);
});
const noopLogger: Logger = {
debug: () => {},
info: () => {},
@@ -557,6 +647,7 @@ test("advertises client capabilities in hello", async () => {
custom_mode_icons: true,
provider_subagents: true,
reasoning_merge_enum: true,
selective_agent_timeline: true,
terminal_reflowable_snapshot: true,
browser_host: {
supportedCommands: ["list_tabs"],
+64
View File
@@ -1,5 +1,6 @@
import type { z } from "zod";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
import {
AgentCreateFailedStatusPayloadSchema,
AgentCreatedStatusPayloadSchema,
@@ -317,6 +318,14 @@ export interface SendMessageOptions {
attachments?: SendAgentMessageRequest["attachments"];
}
export interface AgentAttentionRequiredNotification {
agentId: string;
reason: "finished" | "error" | "permission";
timestamp: string;
shouldNotify: boolean;
notification?: AgentAttentionNotificationPayload;
}
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
export interface CreateAgentRequestOptions extends AgentConfigOverrides {
@@ -1478,6 +1487,31 @@ export class DaemonClient {
};
}
onAgentAttentionRequired(
handler: (notification: AgentAttentionRequiredNotification) => void,
): () => void {
const unsubscribeLegacy = this.on("agent_stream", (message) => {
if (message.payload.event.type !== "attention_required") {
return;
}
const event = message.payload.event;
handler({
agentId: message.payload.agentId,
reason: event.reason,
timestamp: event.timestamp,
shouldNotify: event.shouldNotify,
...(event.notification ? { notification: event.notification } : {}),
});
});
const unsubscribeDedicated = this.on("agent_attention_required", (message) => {
handler(message.payload);
});
return () => {
unsubscribeLegacy();
unsubscribeDedicated();
};
}
// ============================================================================
// Core Send Helpers
// ============================================================================
@@ -2687,6 +2721,35 @@ export class DaemonClient {
return payload;
}
async setAgentTimelineSubscription(agentIds: string[]): Promise<void> {
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their
// legacy global stream and do not understand this RPC. Remove after
// 2027-01-12 once the supported daemon floor is >= v0.1.106.
if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) {
return;
}
const requestId = this.createRequestId();
const normalizedAgentIds = [...new Set(agentIds)].sort();
const message = SessionInboundMessageSchema.parse({
type: "agent.timeline.set_subscription.request",
agentIds: normalizedAgentIds,
requestId,
});
await this.sendRequest({
requestId,
message,
options: { skipQueue: true },
select: (response) => {
if (response.type !== "agent.timeline.set_subscription.response") {
return null;
}
return response.payload.requestId === requestId ? response.payload : null;
},
});
}
async buildAgentForkContext(
agentId: string,
options: AgentForkContextOptions = {},
@@ -5045,6 +5108,7 @@ export class DaemonClient {
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.selectiveAgentTimeline]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
[CLIENT_CAPS.providerSubagents]: true,