mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
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:
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user