mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +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:
@@ -6,7 +6,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization";
|
||||
import {
|
||||
createSetAgentInitializing,
|
||||
refreshAgentInitializationTimeout,
|
||||
} from "@/hooks/use-agent-initialization";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
@@ -16,12 +19,8 @@ import {
|
||||
type TimelineReducerSideEffect,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
isTimelineCatchUpComplete,
|
||||
planResumeTimelineSync,
|
||||
planTimelineCatchUpAfter,
|
||||
planTimelineCatchUpFollowUp,
|
||||
} from "@/timeline/timeline-sync-plan";
|
||||
import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan";
|
||||
import { createViewedTimelineSync, type ViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
|
||||
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
type AgentAttentionNotificationPayload,
|
||||
type NotificationPermissionRequest,
|
||||
} from "@getpaseo/protocol/agent-attention-notification";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
||||
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
|
||||
@@ -54,25 +52,20 @@ import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
createInitDeferred,
|
||||
resolveInitDeferred,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { reconcileWorkspaceDirectory } from "@/contexts/workspace-directory-reconciliation";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
@@ -97,7 +90,6 @@ export type {
|
||||
} from "@/stores/session-store";
|
||||
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
@@ -127,15 +119,29 @@ interface BufferedAudioChunk {
|
||||
interface WorkspaceHydrationSnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
deltas: readonly WorkspaceUpdatePayload[];
|
||||
}
|
||||
|
||||
interface WorkspaceHydrationTransaction {
|
||||
id: symbol;
|
||||
client: DaemonClient;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
deltas: WorkspaceUpdatePayload[];
|
||||
}
|
||||
|
||||
type WorkspaceUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
>["payload"];
|
||||
|
||||
async function fetchWorkspaceHydrationSnapshot(input: {
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
subscribe: boolean;
|
||||
isCancelled?: () => boolean;
|
||||
transaction: WorkspaceHydrationTransaction;
|
||||
isCurrent: () => boolean;
|
||||
}): Promise<WorkspaceHydrationSnapshot | null> {
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = input.subscribe;
|
||||
@@ -146,7 +152,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
|
||||
...(includeSubscribe ? { subscribe: {} } : {}),
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
if (input.isCancelled?.()) {
|
||||
if (input.isCancelled?.() || !input.isCurrent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -155,7 +161,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
workspaces.set(workspace.id, workspace);
|
||||
input.transaction.workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
// Project parents with no active workspaces only ride on the first page.
|
||||
@@ -171,7 +177,11 @@ async function fetchWorkspaceHydrationSnapshot(input: {
|
||||
includeSubscribe = false;
|
||||
}
|
||||
|
||||
return { workspaces, emptyProjects };
|
||||
return {
|
||||
workspaces: new Map(input.transaction.workspaces),
|
||||
emptyProjects,
|
||||
deltas: [...input.transaction.deltas],
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBase64Chunk(base64: string): Uint8Array {
|
||||
@@ -240,54 +250,11 @@ const getLatestPermissionRequest = (
|
||||
return null;
|
||||
};
|
||||
|
||||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level pending agent updates buffer (scoped by serverId)
|
||||
// ---------------------------------------------------------------------------
|
||||
const pendingAgentUpdates = new Map<string, AgentUpdatePayload>();
|
||||
|
||||
function pendingKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
export function bufferPendingAgentUpdate(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
update: AgentUpdatePayload,
|
||||
): void {
|
||||
pendingAgentUpdates.set(pendingKey(serverId, agentId), update);
|
||||
}
|
||||
|
||||
export function flushPendingAgentUpdate(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): AgentUpdatePayload | undefined {
|
||||
const key = pendingKey(serverId, agentId);
|
||||
const update = pendingAgentUpdates.get(key);
|
||||
pendingAgentUpdates.delete(key);
|
||||
return update;
|
||||
}
|
||||
|
||||
export function deletePendingAgentUpdate(serverId: string, agentId: string): void {
|
||||
pendingAgentUpdates.delete(pendingKey(serverId, agentId));
|
||||
}
|
||||
|
||||
export function clearPendingAgentUpdates(serverId: string): void {
|
||||
for (const key of Array.from(pendingAgentUpdates.keys())) {
|
||||
if (key.startsWith(`${serverId}:`)) {
|
||||
pendingAgentUpdates.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
|
||||
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
|
||||
type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"];
|
||||
@@ -401,20 +368,12 @@ function applyTimelineStreamPatches(input: {
|
||||
function executeTimelineSideEffects(input: {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
applyAgentUpdatePayload: (payload: AgentUpdatePayload) => void;
|
||||
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
}): void {
|
||||
const { sideEffects, agentId, serverId, requestCanonicalCatchUp, applyAgentUpdatePayload } =
|
||||
input;
|
||||
const { sideEffects, agentId, recoverTimelineGap } = input;
|
||||
for (const effect of sideEffects) {
|
||||
if (effect.type === "catch_up") {
|
||||
requestCanonicalCatchUp(agentId, effect.cursor);
|
||||
} else if (effect.type === "flush_pending_updates") {
|
||||
const deferredUpdate = flushPendingAgentUpdate(serverId, agentId);
|
||||
if (deferredUpdate) {
|
||||
applyAgentUpdatePayload(deferredUpdate);
|
||||
}
|
||||
recoverTimelineGap(agentId, effect.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,9 +512,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
|
||||
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
||||
@@ -567,32 +526,45 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const focusedTerminalId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedTerminalId ?? null,
|
||||
);
|
||||
const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents);
|
||||
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
const sendAgentMessageRef = useRef<
|
||||
| ((
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
attachments?: AgentAttachment[],
|
||||
) => Promise<void>)
|
||||
| null
|
||||
>(null);
|
||||
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
const revalidationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const revalidationInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const revalidationQueuedRef = useRef(false);
|
||||
const timelineCatchUpInFlightRef = useRef<Set<string>>(new Set());
|
||||
const wasConnectedRef = useRef(isConnected);
|
||||
const viewedTimelineSyncRef = useRef<ViewedTimelineSync | null>(null);
|
||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
const workspaceHydrationRef = useRef<WorkspaceHydrationTransaction | null>(null);
|
||||
|
||||
const applyWorkspaceUpdatePayload = useCallback(
|
||||
(payload: WorkspaceUpdatePayload) => {
|
||||
if (payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: payload.id });
|
||||
removeWorkspaceSetup({ serverId, workspaceId: payload.id });
|
||||
removeWorkspace(serverId, payload.id);
|
||||
if (payload.emptyProject) {
|
||||
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(payload.emptyProject));
|
||||
}
|
||||
if (payload.removedProjectId) removeEmptyProject(serverId, payload.removedProjectId);
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
if (!shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
}
|
||||
},
|
||||
[
|
||||
addEmptyProject,
|
||||
mergeWorkspaces,
|
||||
removeEmptyProject,
|
||||
removeWorkspace,
|
||||
removeWorkspaceSetup,
|
||||
serverId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
appStateRef.current = nextState;
|
||||
viewedTimelineSyncRef.current?.setActive(getIsAppActivelyVisible(nextState));
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -600,26 +572,35 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
|
||||
previousAgentStatusRef.current,
|
||||
sessionAgents,
|
||||
);
|
||||
}, [sessionAgents]);
|
||||
|
||||
const hydrateWorkspaces = useCallback(
|
||||
async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => {
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await fetchWorkspaceHydrationSnapshot({
|
||||
const transaction: WorkspaceHydrationTransaction = {
|
||||
id: Symbol("workspace hydration"),
|
||||
client,
|
||||
serverId,
|
||||
subscribe: options?.subscribe ?? false,
|
||||
isCancelled: options?.isCancelled,
|
||||
});
|
||||
workspaces: new Map(),
|
||||
deltas: [],
|
||||
};
|
||||
workspaceHydrationRef.current = transaction;
|
||||
let snapshot: WorkspaceHydrationSnapshot | null;
|
||||
try {
|
||||
snapshot = await fetchWorkspaceHydrationSnapshot({
|
||||
client,
|
||||
serverId,
|
||||
subscribe: options?.subscribe ?? false,
|
||||
isCancelled: options?.isCancelled,
|
||||
transaction,
|
||||
isCurrent: () => workspaceHydrationRef.current?.id === transaction.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
|
||||
throw error;
|
||||
}
|
||||
if (!snapshot || options?.isCancelled?.()) {
|
||||
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -631,14 +612,28 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
isCancelled: options?.isCancelled,
|
||||
});
|
||||
if (didBackfillLegacy) {
|
||||
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaces(serverId, snapshot.workspaces);
|
||||
setWorkspaces(
|
||||
serverId,
|
||||
reconcileWorkspaceDirectory({ snapshot: snapshot.workspaces, deltas: snapshot.deltas }),
|
||||
);
|
||||
setEmptyProjects(serverId, snapshot.emptyProjects.values());
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
for (const delta of snapshot.deltas) applyWorkspaceUpdatePayload(delta);
|
||||
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
|
||||
},
|
||||
[client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces],
|
||||
[
|
||||
applyWorkspaceUpdatePayload,
|
||||
client,
|
||||
isConnected,
|
||||
serverId,
|
||||
setEmptyProjects,
|
||||
setHasHydratedWorkspaces,
|
||||
setWorkspaces,
|
||||
],
|
||||
);
|
||||
|
||||
const applyAuthoritativeAgentSnapshot = useCallback(
|
||||
@@ -723,168 +718,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const prevStatus = previousAgentStatusRef.current.get(agent.id);
|
||||
if (prevStatus === "running" && agent.status !== "running") {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const queue = session?.queuedMessages.get(agent.id);
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
if (sendAgentMessageRef.current) {
|
||||
const supportsForgeSearch =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
});
|
||||
void sendAgentMessageRef.current(
|
||||
agent.id,
|
||||
next.text,
|
||||
wirePayload.images,
|
||||
wirePayload.attachments,
|
||||
);
|
||||
}
|
||||
setQueuedMessages(serverId, (prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(agent.id, rest);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
previousAgentStatusRef.current.set(agent.id, agent.status);
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
serverId,
|
||||
setAgentLastActivity,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
setQueuedMessages,
|
||||
],
|
||||
[queryClient, serverId, setAgentLastActivity, setAgents, setPendingPermissions],
|
||||
);
|
||||
|
||||
const runAuthoritativeRevalidation = useCallback(async () => {
|
||||
await Promise.all([
|
||||
getHostRuntimeStore().refreshAgentDirectory({ serverId }),
|
||||
hydrateWorkspaces(),
|
||||
]);
|
||||
}, [hydrateWorkspaces, serverId]);
|
||||
|
||||
const flushAuthoritativeRevalidation = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
if (revalidationInFlightRef.current) {
|
||||
revalidationQueuedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const run = runAuthoritativeRevalidation()
|
||||
.catch((error) => {
|
||||
console.error("[Session] authoritative revalidation failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (revalidationInFlightRef.current === run) {
|
||||
revalidationInFlightRef.current = null;
|
||||
}
|
||||
if (!revalidationQueuedRef.current) {
|
||||
return;
|
||||
}
|
||||
revalidationQueuedRef.current = false;
|
||||
if (revalidationTimerRef.current) {
|
||||
clearTimeout(revalidationTimerRef.current);
|
||||
}
|
||||
revalidationTimerRef.current = setTimeout(() => {
|
||||
revalidationTimerRef.current = null;
|
||||
flushAuthoritativeRevalidation();
|
||||
}, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
revalidationInFlightRef.current = run;
|
||||
}, [client, isConnected, runAuthoritativeRevalidation, serverId]);
|
||||
|
||||
const scheduleAuthoritativeRevalidation = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidationQueuedRef.current = true;
|
||||
if (revalidationTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
revalidationTimerRef.current = setTimeout(() => {
|
||||
revalidationTimerRef.current = null;
|
||||
if (!revalidationQueuedRef.current) {
|
||||
return;
|
||||
}
|
||||
revalidationQueuedRef.current = false;
|
||||
flushAuthoritativeRevalidation();
|
||||
}, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS);
|
||||
}, [client, flushAuthoritativeRevalidation, isConnected]);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
const recoverTimelineGap = useCallback(
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
const request = planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq });
|
||||
const key = `${agentId}:${request.cursor.epoch}:${request.cursor.seq}`;
|
||||
const inFlight = timelineCatchUpInFlightRef.current;
|
||||
if (inFlight.has(key)) {
|
||||
return;
|
||||
}
|
||||
inFlight.add(key);
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, request)
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(key);
|
||||
});
|
||||
viewedTimelineSyncRef.current?.recoverGap(agentId, cursor);
|
||||
},
|
||||
[client],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAppResumed = useCallback(
|
||||
(awayMs: number) => {
|
||||
scheduleAuthoritativeRevalidation();
|
||||
|
||||
if (isNative) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agentId = session?.focusedAgentId;
|
||||
if (agentId) {
|
||||
const plan = planResumeTimelineSync({
|
||||
cursor: session?.agentTimelineCursor.get(agentId),
|
||||
});
|
||||
if (plan.direction === "after") {
|
||||
requestCanonicalCatchUp(agentId, {
|
||||
epoch: plan.cursor.epoch,
|
||||
endSeq: plan.cursor.seq,
|
||||
});
|
||||
} else {
|
||||
void client.fetchAgentTimeline(agentId, plan).catch((error) => {
|
||||
console.warn("[Session] failed to fetch tail timeline on resume", agentId, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (awayMs < HISTORY_STALE_AFTER_MS) {
|
||||
return;
|
||||
}
|
||||
bumpHistorySyncGeneration(serverId);
|
||||
},
|
||||
[
|
||||
bumpHistorySyncGeneration,
|
||||
client,
|
||||
requestCanonicalCatchUp,
|
||||
scheduleAuthoritativeRevalidation,
|
||||
serverId,
|
||||
],
|
||||
[bumpHistorySyncGeneration, serverId],
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
@@ -944,11 +796,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
// Initialize session in store
|
||||
useEffect(() => {
|
||||
initializeSession(serverId, client);
|
||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
||||
initializeSession(serverId, client, generation);
|
||||
}, [serverId, client, initializeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSessionClient(serverId, client);
|
||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
||||
updateSessionClient(serverId, client, generation);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1024,7 +878,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
flushAgentLastActivity();
|
||||
clearPendingAgentUpdates(serverId);
|
||||
setInitializingAgents(serverId, new Map());
|
||||
}
|
||||
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
||||
@@ -1051,85 +904,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
};
|
||||
}, [client, hydrateWorkspaces, isConnected]);
|
||||
|
||||
const applyAgentUpdatePayload = useCallback(
|
||||
(update: AgentUpdatePayload) => {
|
||||
if (update.kind === "remove") {
|
||||
const agentId = update.agentId;
|
||||
previousAgentStatusRef.current.delete(agentId);
|
||||
deletePendingAgentUpdate(serverId, agentId);
|
||||
clearArchiveAgentPending({ queryClient, serverId, agentId });
|
||||
|
||||
setAgents(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [key, pending] of Array.from(next.entries())) {
|
||||
if (pending.agentId === agentId) {
|
||||
next.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
setQueuedMessages(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgentSnapshot(update.agent, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({
|
||||
serverId,
|
||||
agent: {
|
||||
...normalized,
|
||||
projectPlacement: resolveProjectPlacement({
|
||||
projectPlacement: update.project,
|
||||
cwd: normalized.cwd,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
applyAuthoritativeAgentSnapshot(agent);
|
||||
},
|
||||
[
|
||||
applyAuthoritativeAgentSnapshot,
|
||||
queryClient,
|
||||
serverId,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
setAgents,
|
||||
setAgentTimelineCursor,
|
||||
setPendingPermissions,
|
||||
setQueuedMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const applyWorkspaceSetupProgress = useCallback(
|
||||
(payload: WorkspaceSetupProgressPayload) => {
|
||||
upsertWorkspaceSetupProgress({ serverId, payload });
|
||||
@@ -1222,34 +996,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
executeTimelineSideEffects({
|
||||
sideEffects: result.sideEffects,
|
||||
agentId,
|
||||
serverId,
|
||||
requestCanonicalCatchUp,
|
||||
applyAgentUpdatePayload,
|
||||
recoverTimelineGap,
|
||||
});
|
||||
|
||||
const followUp = planTimelineCatchUpFollowUp({
|
||||
direction: payload.direction,
|
||||
hasNewer: payload.hasNewer,
|
||||
endCursor: payload.endCursor,
|
||||
error: payload.error,
|
||||
});
|
||||
if (followUp?.direction === "after") {
|
||||
refreshAgentInitializationTimeout({
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing: (id, initializing) => {
|
||||
if (initializing) {
|
||||
return;
|
||||
}
|
||||
clearAgentInitializingFlag(setInitializingAgents, serverId, id);
|
||||
},
|
||||
});
|
||||
requestCanonicalCatchUp(agentId, {
|
||||
epoch: followUp.cursor.epoch,
|
||||
endSeq: followUp.cursor.seq,
|
||||
});
|
||||
}
|
||||
|
||||
finalizeTimelineApplication({
|
||||
result,
|
||||
agentId,
|
||||
@@ -1263,10 +1012,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
[
|
||||
applyAuthoritativeAgentSnapshot,
|
||||
applyAgentUpdatePayload,
|
||||
clearAgentStreamHead,
|
||||
markAgentHistorySynchronized,
|
||||
requestCanonicalCatchUp,
|
||||
recoverTimelineGap,
|
||||
serverId,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
setAgentStreamHead,
|
||||
@@ -1278,54 +1026,67 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
return;
|
||||
}
|
||||
clearPendingAgentUpdates(serverId);
|
||||
}, [isConnected, serverId]);
|
||||
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
|
||||
const sync = createViewedTimelineSync({
|
||||
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
|
||||
readCursor: (agentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
if (
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) !== true &&
|
||||
!getInitDeferred(initKey)
|
||||
) {
|
||||
createInitDeferred(initKey, request.direction ?? "tail");
|
||||
refreshAgentInitializationTimeout({
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing,
|
||||
});
|
||||
setAgentInitializing(agentId, true);
|
||||
}
|
||||
try {
|
||||
return await client.fetchAgentTimeline(agentId, request);
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
reportError: (error) => {
|
||||
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
||||
},
|
||||
scheduleRetry: (retry) => {
|
||||
const timeout = setTimeout(retry, 1_000);
|
||||
return () => clearTimeout(timeout);
|
||||
},
|
||||
});
|
||||
viewedTimelineSyncRef.current = sync;
|
||||
setViewedTimelineSync(serverId, sync);
|
||||
sync.setActive(getIsAppActivelyVisible(appStateRef.current));
|
||||
|
||||
useEffect(() => {
|
||||
const wasConnected = wasConnectedRef.current;
|
||||
wasConnectedRef.current = isConnected;
|
||||
if (!wasConnected && isConnected) {
|
||||
scheduleAuthoritativeRevalidation();
|
||||
}
|
||||
}, [isConnected, scheduleAuthoritativeRevalidation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (revalidationTimerRef.current) {
|
||||
clearTimeout(revalidationTimerRef.current);
|
||||
if (viewedTimelineSyncRef.current === sync) {
|
||||
viewedTimelineSyncRef.current = null;
|
||||
}
|
||||
setViewedTimelineSync(serverId, null);
|
||||
sync.dispose();
|
||||
};
|
||||
}, []);
|
||||
}, [client, serverId, setInitializingAgents, setViewedTimelineSync]);
|
||||
|
||||
useEffect(() => {
|
||||
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
||||
}, [isConnected]);
|
||||
|
||||
// Daemon message handlers - directly update Zustand store
|
||||
useEffect(() => {
|
||||
const unsubAgentUpdate = client.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
const update = message.payload;
|
||||
const agentId = getAgentIdFromUpdate(update);
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const isSyncingHistory =
|
||||
session?.initializingAgents.get(agentId) === true && Boolean(getInitDeferred(initKey));
|
||||
|
||||
if (isSyncingHistory) {
|
||||
bufferPendingAgentUpdate(serverId, agentId, update);
|
||||
return;
|
||||
}
|
||||
|
||||
deletePendingAgentUpdate(serverId, agentId);
|
||||
applyAgentUpdatePayload(update);
|
||||
});
|
||||
|
||||
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
setAgents,
|
||||
requestCanonicalCatchUp,
|
||||
recoverTimelineGap,
|
||||
});
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
@@ -1342,18 +1103,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
|
||||
}
|
||||
|
||||
// Attention notification stays in React (not extractable to pure reducer)
|
||||
if (event.type === "attention_required") {
|
||||
if (event.shouldNotify) {
|
||||
notifyAgentAttention({
|
||||
agentId,
|
||||
reason: event.reason,
|
||||
timestamp: event.timestamp,
|
||||
notification: event.notification,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
agentStreamReducerQueue.enqueue(agentId, {
|
||||
event: streamEvent,
|
||||
seq,
|
||||
@@ -1366,6 +1115,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
// on status changes, which is sufficient for sorting and display purposes.
|
||||
});
|
||||
|
||||
const unsubAgentAttention = client.onAgentAttentionRequired((notification) => {
|
||||
if (notification.shouldNotify) {
|
||||
notifyAgentAttention(notification);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
|
||||
if (message.type !== "fetch_agent_timeline_response") return;
|
||||
agentStreamReducerQueue.flushAgent(message.payload.agentId);
|
||||
@@ -1379,26 +1134,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: message.payload.id,
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
if (message.payload.emptyProject) {
|
||||
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
|
||||
}
|
||||
if (message.payload.removedProjectId) {
|
||||
removeEmptyProject(serverId, message.payload.removedProjectId);
|
||||
}
|
||||
const hydration = workspaceHydrationRef.current;
|
||||
if (hydration) {
|
||||
hydration.deltas.push(message.payload);
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
applyWorkspaceUpdatePayload(message.payload);
|
||||
});
|
||||
|
||||
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
|
||||
@@ -1667,7 +1408,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
return;
|
||||
}
|
||||
const { agentId } = message.payload;
|
||||
deletePendingAgentUpdate(serverId, agentId);
|
||||
clearArchiveAgentPending({ queryClient, serverId, agentId });
|
||||
|
||||
setAgents(serverId, (prev) => {
|
||||
@@ -1780,10 +1520,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubProviderSubagentUpdate();
|
||||
unsubAgentAttention();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubScriptStatusUpdate();
|
||||
unsubCheckoutStatusUpdate();
|
||||
@@ -1827,8 +1567,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setHasHydratedAgents,
|
||||
clearDraftInput,
|
||||
notifyAgentAttention,
|
||||
requestCanonicalCatchUp,
|
||||
applyAgentUpdatePayload,
|
||||
recoverTimelineGap,
|
||||
applyWorkspaceUpdatePayload,
|
||||
applyWorkspaceSetupProgress,
|
||||
applyTimelineResponse,
|
||||
updateSessionServerInfo,
|
||||
@@ -1837,67 +1577,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
voiceAudioEngine,
|
||||
]);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
async (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
attachments?: AgentAttachment[],
|
||||
) => {
|
||||
const messageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
text: message,
|
||||
timestamp: new Date(),
|
||||
optimistic: true,
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
};
|
||||
|
||||
// Append to head if streaming (keeps the user message with the current
|
||||
// turn so late text_deltas still find the existing assistant_message).
|
||||
// Otherwise append to tail.
|
||||
const currentHead = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.agentStreamHead?.get(agentId);
|
||||
if (currentHead && currentHead.length > 0) {
|
||||
setAgentStreamHead(serverId, (prev) => {
|
||||
const head = prev.get(agentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(agentId, [...head, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const currentStream = prev.get(agentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(agentId, [...currentStream, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
if (!client) {
|
||||
console.warn("[Session] sendAgentMessage skipped: daemon unavailable");
|
||||
return;
|
||||
}
|
||||
void client
|
||||
.sendAgentMessage(agentId, message, {
|
||||
messageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Session] Failed to send agent message:", error);
|
||||
});
|
||||
},
|
||||
[serverId, client, setAgentStreamTail, setAgentStreamHead],
|
||||
);
|
||||
|
||||
// Keep the ref updated so the agent_update handler can call it
|
||||
sendAgentMessageRef.current = sendAgentMessage;
|
||||
|
||||
const _cancelAgentRun = useCallback(
|
||||
(agentId: string) => {
|
||||
if (!client) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import { normalizeWorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation";
|
||||
|
||||
function workspace(id: string, title: string): WorkspaceDescriptorPayload {
|
||||
return {
|
||||
id,
|
||||
projectId: "project",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: id,
|
||||
title,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps workspace upserts and removals received during later pages", () => {
|
||||
const result = reconcileWorkspaceDirectory({
|
||||
snapshot: new Map([
|
||||
["updated", normalizeWorkspaceDescriptor(workspace("updated", "snapshot"))],
|
||||
["removed", normalizeWorkspaceDescriptor(workspace("removed", "snapshot"))],
|
||||
]),
|
||||
deltas: [
|
||||
{ kind: "upsert", workspace: workspace("updated", "live") },
|
||||
{ kind: "remove", id: "removed" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(Array.from(result.values()).map(({ id, title }) => [id, title])).toEqual([
|
||||
["updated", "live"],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
type WorkspaceDelta = Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
|
||||
export function reconcileWorkspaceDirectory(input: {
|
||||
snapshot: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
deltas: readonly WorkspaceDelta[];
|
||||
}): Map<string, WorkspaceDescriptor> {
|
||||
const workspaces = new Map(input.snapshot);
|
||||
for (const delta of input.deltas) {
|
||||
if (delta.kind === "remove") {
|
||||
workspaces.delete(delta.id);
|
||||
} else {
|
||||
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
}
|
||||
return workspaces;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
||||
@@ -40,10 +40,7 @@ import {
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import {
|
||||
clearHistorySyncErrorAfterSuccessfulSync,
|
||||
reconcileMissingAgentStateWithPresentAgent,
|
||||
} from "@/panels/agent-panel-load-state";
|
||||
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
@@ -782,44 +779,6 @@ function ChatAgentContent({
|
||||
mode: "translate",
|
||||
});
|
||||
|
||||
const handleHistorySyncFailure = useCallback(
|
||||
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
|
||||
if (agentId) {
|
||||
console.warn("[AgentPanel] history sync failed", {
|
||||
origin,
|
||||
agentId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
setMissingAgentState((previous) => {
|
||||
if (previous.kind === "error" && previous.message === message) {
|
||||
return previous;
|
||||
}
|
||||
return { kind: "error", message };
|
||||
});
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
const ensureInitializedWithSyncErrorHandling = useCallback(
|
||||
(origin: "focus" | "entry") => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
ensureAgentIsInitialized(agentId)
|
||||
.then(() => {
|
||||
setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
handleHistorySyncFailure({ origin, error });
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
[agentId, ensureAgentIsInitialized, handleHistorySyncFailure],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === "online") {
|
||||
if (reconnectToastArmedRef.current) {
|
||||
@@ -840,13 +799,6 @@ function ChatAgentContent({
|
||||
}
|
||||
}, [connectionStatus, dismissToast, toastApi, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
ensureInitializedWithSyncErrorHandling("focus");
|
||||
}, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected, isPaneFocused]);
|
||||
|
||||
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -946,27 +898,6 @@ function ChatAgentContent({
|
||||
streamViewRef.current?.scrollToBottom("message-sent");
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
|
||||
if (!shouldSyncOnEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureInitializedWithSyncErrorHandling("entry");
|
||||
}, [
|
||||
agentId,
|
||||
ensureInitializedWithSyncErrorHandling,
|
||||
hasSession,
|
||||
isConnected,
|
||||
needsAuthoritativeSync,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
initAttemptTokenRef.current += 1;
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true });
|
||||
});
|
||||
import type {
|
||||
DaemonClient,
|
||||
ConnectionState,
|
||||
@@ -10,8 +6,13 @@ import type {
|
||||
FetchAgentsOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
HostRuntimeStore,
|
||||
@@ -20,10 +21,6 @@ import {
|
||||
type HostRuntimeStorage,
|
||||
} from "./host-runtime";
|
||||
|
||||
vi.mock("@/browser-automation/handler", () => ({
|
||||
mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
@@ -33,7 +30,43 @@ class FakeDaemonClient {
|
||||
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
|
||||
public connectCalls = 0;
|
||||
public fetchAgentsCalls: FetchAgentsOptions[] = [];
|
||||
public fetchAgentsResponses: Awaited<ReturnType<DaemonClient["fetchAgents"]>>[] = [];
|
||||
public fetchAgentsResponses: Array<
|
||||
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
|
||||
> = [];
|
||||
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
|
||||
private agentUpdateListeners = new Set<
|
||||
(message: Extract<SessionOutboundMessage, { type: "agent_update" }>) => void
|
||||
>();
|
||||
private fetchWaiters = new Set<() => void>();
|
||||
private agentListenerWaiters = new Set<() => void>();
|
||||
private sentMessageWaiters = new Set<() => void>();
|
||||
|
||||
on(
|
||||
type: "agent_update",
|
||||
listener: (message: Extract<SessionOutboundMessage, { type: "agent_update" }>) => void,
|
||||
): () => void {
|
||||
if (type === "agent_update") this.agentUpdateListeners.add(listener);
|
||||
for (const waiter of this.agentListenerWaiters) waiter();
|
||||
return () => this.agentUpdateListeners.delete(listener);
|
||||
}
|
||||
|
||||
async waitForAgentUpdates(): Promise<void> {
|
||||
if (this.agentUpdateListeners.size > 0) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const waiter = () => {
|
||||
if (this.agentUpdateListeners.size === 0) return;
|
||||
this.agentListenerWaiters.delete(waiter);
|
||||
resolve();
|
||||
};
|
||||
this.agentListenerWaiters.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
agentUpdate(payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"]): void {
|
||||
for (const listener of this.agentUpdateListeners) {
|
||||
listener({ type: "agent_update", payload });
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connectCalls += 1;
|
||||
@@ -44,6 +77,23 @@ class FakeDaemonClient {
|
||||
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
|
||||
}
|
||||
|
||||
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
|
||||
this.sentAgentMessages.push(args);
|
||||
for (const waiter of this.sentMessageWaiters) waiter();
|
||||
}
|
||||
|
||||
async waitForSentMessages(count: number): Promise<void> {
|
||||
if (this.sentAgentMessages.length >= count) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const waiter = () => {
|
||||
if (this.sentAgentMessages.length < count) return;
|
||||
this.sentMessageWaiters.delete(waiter);
|
||||
resolve();
|
||||
};
|
||||
this.sentMessageWaiters.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
ensureConnected(): void {
|
||||
if (this.state.status !== "connected") {
|
||||
this.setConnectionState({ status: "connected" });
|
||||
@@ -70,9 +120,10 @@ class FakeDaemonClient {
|
||||
options?: FetchAgentsOptions,
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
this.fetchAgentsCalls.push(options ?? {});
|
||||
for (const waiter of this.fetchWaiters) waiter();
|
||||
const queued = this.fetchAgentsResponses.shift();
|
||||
if (queued) {
|
||||
return queued;
|
||||
return await queued;
|
||||
}
|
||||
return makeFetchAgentsPayload({
|
||||
entries: [],
|
||||
@@ -80,6 +131,18 @@ class FakeDaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async waitForFetches(count: number): Promise<void> {
|
||||
if (this.fetchAgentsCalls.length >= count) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const waiter = () => {
|
||||
if (this.fetchAgentsCalls.length < count) return;
|
||||
this.fetchWaiters.delete(waiter);
|
||||
resolve();
|
||||
};
|
||||
this.fetchWaiters.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
async ping(): Promise<{ rttMs: number }> {
|
||||
return { rttMs: 0 };
|
||||
}
|
||||
@@ -160,6 +223,32 @@ function makeFetchAgentsPayload(input: {
|
||||
};
|
||||
}
|
||||
|
||||
class Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
private resolvePromise!: (value: T) => void;
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise((resolve) => {
|
||||
this.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
resolve(value: T): void {
|
||||
this.resolvePromise(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise<void> {
|
||||
if (store.getSnapshot(serverId)?.agentDirectoryStatus === "ready") return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = store.subscribe(serverId, () => {
|
||||
if (store.getSnapshot(serverId)?.agentDirectoryStatus !== "ready") return;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function makeFetchAgentsEntry(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
@@ -220,6 +309,14 @@ function makeFetchAgentsEntry(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Agent {
|
||||
return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null };
|
||||
}
|
||||
|
||||
function agentPermission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
|
||||
function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
@@ -1322,13 +1419,11 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
@@ -1342,11 +1437,18 @@ describe("HostRuntimeStore", () => {
|
||||
expect(snapshot?.agentDirectoryStatus).toBe("ready");
|
||||
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
|
||||
|
||||
await store.refreshAgentDirectory({ serverId: host.serverId });
|
||||
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
|
||||
scope: "active",
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
page: { limit: 200 },
|
||||
});
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("bootstraps agent directory immediately when connection goes online (no session required)", async () => {
|
||||
it("waits for the matching session replica before committing the connected client bootstrap", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_no_session",
|
||||
connections: [
|
||||
@@ -1373,10 +1475,14 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await Promise.resolve();
|
||||
expect(fakeClient.fetchAgentsCalls).toEqual([]);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
@@ -1387,6 +1493,7 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => {
|
||||
@@ -1428,7 +1535,7 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
@@ -1436,14 +1543,8 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
while (
|
||||
(fakeClient.fetchAgentsCalls.length === 0 ||
|
||||
!useSessionStore.getState().sessions[host.serverId]?.workspaces.has("/repo/legacy-app")) &&
|
||||
Date.now() < timeoutAt
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toEqual([
|
||||
{
|
||||
@@ -1466,6 +1567,98 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("drains legacy snapshot and buffered running transitions exactly once", async () => {
|
||||
const host = makeHost({ serverId: "srv_legacy_transitions" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
|
||||
const snapshotAgent = makeFetchAgentsEntry({
|
||||
id: "legacy-snapshot",
|
||||
cwd: "/legacy/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
const bufferedAgent = makeFetchAgentsEntry({
|
||||
id: "legacy-buffered",
|
||||
cwd: "/legacy/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
{ ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } },
|
||||
{ ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } },
|
||||
],
|
||||
hasMore: true,
|
||||
nextCursor: "legacy-page-two",
|
||||
}),
|
||||
pageTwo.promise,
|
||||
);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_legacy_transitions",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
version: "0.1.96",
|
||||
});
|
||||
sessionStore.setAgents(
|
||||
host.serverId,
|
||||
new Map([
|
||||
[
|
||||
"legacy-snapshot",
|
||||
{ ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" },
|
||||
],
|
||||
[
|
||||
"legacy-buffered",
|
||||
{ ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" },
|
||||
],
|
||||
]),
|
||||
);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([
|
||||
["legacy-snapshot", [{ id: "legacy-snapshot-message", text: "snapshot", attachments: [] }]],
|
||||
["legacy-buffered", [{ id: "legacy-buffered-message", text: "buffered", attachments: [] }]],
|
||||
]),
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(2);
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: { ...bufferedAgent.agent, status: "idle" },
|
||||
project: bufferedAgent.project,
|
||||
});
|
||||
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
await fakeClient.waitForSentMessages(2);
|
||||
|
||||
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
|
||||
["legacy-snapshot", "snapshot"],
|
||||
["legacy-buffered", "buffered"],
|
||||
]);
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
|
||||
({ id, status, workspaceId }) => [id, status, workspaceId],
|
||||
),
|
||||
).toEqual([
|
||||
["legacy-snapshot", "idle", "/legacy/repo"],
|
||||
["legacy-buffered", "idle", "/legacy/repo"],
|
||||
]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("fetches all pages during bootstrap within the active agent scope", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_paged",
|
||||
@@ -1521,13 +1714,11 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await fakeClient.waitForFetches(2);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
|
||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||
@@ -1542,16 +1733,9 @@ describe("HostRuntimeStore", () => {
|
||||
page: { limit: 200, cursor: "cursor-page-2" },
|
||||
});
|
||||
|
||||
let staleAgent =
|
||||
const staleAgent =
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ??
|
||||
null;
|
||||
const staleTimeoutAt = Date.now() + 300;
|
||||
while (!staleAgent && Date.now() < staleTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
staleAgent =
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ??
|
||||
null;
|
||||
}
|
||||
expect(staleAgent?.requiresAttention).toBe(true);
|
||||
expect(staleAgent?.attentionReason).toBe("error");
|
||||
|
||||
@@ -1563,6 +1747,444 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("replays agent updates received while a later bootstrap page is loading", async () => {
|
||||
const host = makeHost({ serverId: "srv_paged_delta" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
let finishPageTwo!: (payload: Awaited<ReturnType<DaemonClient["fetchAgents"]>>) => void;
|
||||
const pageTwo = new Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>((resolve) => {
|
||||
finishPageTwo = resolve;
|
||||
});
|
||||
const pageOneAgent = makeFetchAgentsEntry({
|
||||
id: "agent-a",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
title: "snapshot",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [pageOneAgent],
|
||||
hasMore: true,
|
||||
nextCursor: "page-two",
|
||||
subscriptionId: "app:srv_paged_delta",
|
||||
}),
|
||||
pageTwo,
|
||||
);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_paged_delta",
|
||||
},
|
||||
});
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(2);
|
||||
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: { ...pageOneAgent.agent, title: "live" },
|
||||
project: pageOneAgent.project,
|
||||
});
|
||||
finishPageTwo(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "agent-b",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T09:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
|
||||
(agent) => [agent.id, agent.title],
|
||||
),
|
||||
).toEqual([
|
||||
["agent-a", "live"],
|
||||
["agent-b", null],
|
||||
]);
|
||||
|
||||
const agentB = makeFetchAgentsEntry({
|
||||
id: "agent-b",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
title: "immediate",
|
||||
});
|
||||
fakeClient.agentUpdate({ kind: "upsert", agent: agentB.agent, project: agentB.project });
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-b")?.title).toBe(
|
||||
"immediate",
|
||||
);
|
||||
fakeClient.agentUpdate({ kind: "remove", agentId: "agent-b" });
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-b")).toBe(false);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("buffers updates until the matching session generation exists", async () => {
|
||||
const host = makeHost({ serverId: "srv_pre_session" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const snapshotEntry = makeFetchAgentsEntry({
|
||||
id: "agent-pre-session",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
title: "snapshot",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [snapshotEntry] }));
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_pre_session",
|
||||
},
|
||||
});
|
||||
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForAgentUpdates();
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: { ...snapshotEntry.agent, title: "before-session" },
|
||||
project: snapshotEntry.project,
|
||||
});
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-pre-session")?.title,
|
||||
).toBe("before-session");
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("rejects a superseded refresh without overwriting the newer replica", async () => {
|
||||
const host = makeHost({ serverId: "srv_overlap" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_overlap",
|
||||
},
|
||||
});
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
const olderPage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
|
||||
fakeClient.fetchAgentsResponses.push(olderPage.promise);
|
||||
const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
|
||||
await fakeClient.waitForFetches(2);
|
||||
const newerEntry = makeFetchAgentsEntry({
|
||||
id: "newer",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [newerEntry] }));
|
||||
|
||||
const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
|
||||
await fakeClient.waitForFetches(3);
|
||||
await newerRefresh;
|
||||
olderPage.resolve(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "older",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T09:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await expect(olderRefresh).rejects.toThrow();
|
||||
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: { ...newerEntry.agent, title: "after cleanup" },
|
||||
project: newerEntry.project,
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
|
||||
({ id, title }) => [id, title],
|
||||
),
|
||||
).toEqual([["newer", "after cleanup"]]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("rejects a refresh when the session generation changes before commit", async () => {
|
||||
const host = makeHost({ serverId: "srv_stale_generation" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const existingEntry = makeFetchAgentsEntry({
|
||||
id: "existing",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [existingEntry] }));
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_stale_generation",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
const stalePage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
|
||||
fakeClient.fetchAgentsResponses.push(stalePage.promise);
|
||||
|
||||
const refresh = store.refreshAgentDirectory({ serverId: host.serverId });
|
||||
await fakeClient.waitForFetches(2);
|
||||
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 2);
|
||||
stalePage.resolve(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "stale",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(refresh).rejects.toThrow();
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.keys() ?? []),
|
||||
).toEqual(["existing"]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("drains queued messages once for snapshot and buffered running transitions", async () => {
|
||||
const host = makeHost({ serverId: "srv_queued_transitions" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
|
||||
const snapshotAgent = makeFetchAgentsEntry({
|
||||
id: "snapshot-transition",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
const bufferedAgent = makeFetchAgentsEntry({
|
||||
id: "buffered-transition",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
{ ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } },
|
||||
{ ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } },
|
||||
],
|
||||
hasMore: true,
|
||||
nextCursor: "page-two",
|
||||
}),
|
||||
pageTwo.promise,
|
||||
);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_queued_transitions",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setAgents(
|
||||
host.serverId,
|
||||
new Map([
|
||||
[
|
||||
"snapshot-transition",
|
||||
{ ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" },
|
||||
],
|
||||
[
|
||||
"buffered-transition",
|
||||
{ ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" },
|
||||
],
|
||||
]),
|
||||
);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([
|
||||
[
|
||||
"snapshot-transition",
|
||||
[{ id: "message-snapshot", text: "snapshot queued", attachments: [] }],
|
||||
],
|
||||
[
|
||||
"buffered-transition",
|
||||
[{ id: "message-buffered", text: "buffered queued", attachments: [] }],
|
||||
],
|
||||
]),
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(2);
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: { ...bufferedAgent.agent, status: "idle" },
|
||||
project: bufferedAgent.project,
|
||||
});
|
||||
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
await fakeClient.waitForSentMessages(2);
|
||||
|
||||
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
|
||||
["snapshot-transition", "snapshot queued"],
|
||||
["buffered-transition", "buffered queued"],
|
||||
]);
|
||||
expect(
|
||||
Array.from(useSessionStore.getState().sessions[host.serverId]?.queuedMessages.values() ?? []),
|
||||
).toEqual([[], []]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("applies buffered stale side effects from the accepted page agent", async () => {
|
||||
const host = makeHost({ serverId: "srv_buffered_stale_side_effects" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
|
||||
const base = makeFetchAgentsEntry({
|
||||
id: "stale-side-effects",
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T12:00:00.000Z",
|
||||
title: "newer page",
|
||||
});
|
||||
const pageAgent = {
|
||||
...base.agent,
|
||||
status: "running" as const,
|
||||
lastUsage: { inputTokens: 10, outputTokens: 5 },
|
||||
pendingPermissions: [agentPermission("current-permission")],
|
||||
};
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [{ ...base, agent: pageAgent }],
|
||||
hasMore: true,
|
||||
nextCursor: "page-two",
|
||||
}),
|
||||
pageTwo.promise,
|
||||
);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_buffered_stale_side_effects",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setAgents(
|
||||
host.serverId,
|
||||
new Map([
|
||||
[
|
||||
pageAgent.id,
|
||||
replicaAgent({ ...pageAgent, updatedAt: "2026-07-12T10:00:00.000Z" }, host.serverId),
|
||||
],
|
||||
]),
|
||||
);
|
||||
sessionStore.setAgentLastActivity(pageAgent.id, new Date("2026-07-12T12:00:00.000Z"));
|
||||
sessionStore.flushAgentLastActivity();
|
||||
setAgentArchiving({
|
||||
queryClient,
|
||||
serverId: host.serverId,
|
||||
agentId: pageAgent.id,
|
||||
isArchiving: true,
|
||||
});
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(2);
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
...pageAgent,
|
||||
status: "idle",
|
||||
title: "stale live",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
lastUsage: { inputTokens: 20, outputTokens: 8 },
|
||||
pendingPermissions: [agentPermission("stale-permission")],
|
||||
archivedAt: "2026-07-12T11:00:00.000Z",
|
||||
},
|
||||
project: base.project,
|
||||
});
|
||||
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
sessionStore.flushAgentLastActivity();
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
const agent = state.sessions[host.serverId]?.agents.get(pageAgent.id);
|
||||
expect({
|
||||
title: agent?.title,
|
||||
status: agent?.status,
|
||||
usage: agent?.lastUsage,
|
||||
permissions: Array.from(state.sessions[host.serverId]?.pendingPermissions.values() ?? []).map(
|
||||
({ request }) => request.id,
|
||||
),
|
||||
archivePending: isAgentArchiving({
|
||||
queryClient,
|
||||
serverId: host.serverId,
|
||||
agentId: pageAgent.id,
|
||||
}),
|
||||
activity: state.agentLastActivity.get(pageAgent.id)?.toISOString(),
|
||||
sentMessages: fakeClient.sentAgentMessages.length,
|
||||
}).toEqual({
|
||||
title: "newer page",
|
||||
status: "running",
|
||||
usage: { inputTokens: 20, outputTokens: 8 },
|
||||
permissions: ["current-permission"],
|
||||
archivePending: true,
|
||||
activity: "2026-07-12T12:00:00.000Z",
|
||||
sentMessages: 0,
|
||||
});
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("re-subscribes agent directory updates after reconnect", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_resubscribe",
|
||||
@@ -1590,13 +2212,16 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
|
||||
const initialTimeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||
|
||||
fakeClient.setConnectionState({
|
||||
status: "disconnected",
|
||||
@@ -1604,10 +2229,8 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
|
||||
const reconnectTimeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await fakeClient.waitForFetches(2);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toEqual([
|
||||
{
|
||||
@@ -1661,7 +2284,7 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
useSessionStore.getState().setAgents(host.serverId, () => {
|
||||
const stale = makeFetchAgentsEntry({
|
||||
id: "agent-archived",
|
||||
@@ -1686,13 +2309,8 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
while (
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") &&
|
||||
Date.now() < timeoutAt
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe(
|
||||
false,
|
||||
|
||||
@@ -40,11 +40,18 @@ import {
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
fetchLegacyDaemonWorkspaceDirectory,
|
||||
applyAgentDirectoryDelta,
|
||||
replaceFetchedAgentDirectory,
|
||||
type AgentDirectoryDelta,
|
||||
} from "@/utils/agent-directory-sync";
|
||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
readLegacyDaemonWorkspaceDirectory,
|
||||
replaceLegacyDaemonWorkspaceDirectory,
|
||||
shouldUseLegacyDaemonWorkspaceDirectory,
|
||||
stampLegacyWorkspaceIds,
|
||||
} from "@/workspace/legacy-daemon-workspaces";
|
||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
@@ -54,6 +61,8 @@ import {
|
||||
} from "@/data/push-router";
|
||||
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
|
||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
@@ -217,10 +226,13 @@ interface AgentDirectoryFetchInput {
|
||||
filter?: FetchAgentsOptions["filter"];
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
page?: FetchAgentsOptions["page"];
|
||||
transaction: AgentDirectoryTransaction;
|
||||
isCurrent: () => boolean;
|
||||
}
|
||||
|
||||
interface AgentDirectoryFetchResult {
|
||||
entries: FetchAgentsEntry[];
|
||||
deltas: readonly AgentDirectoryDelta[];
|
||||
subscriptionId: string | null;
|
||||
}
|
||||
|
||||
@@ -231,7 +243,6 @@ async function fetchCurrentAgentDirectory(
|
||||
let cursor = input.page?.cursor ?? null;
|
||||
let includeSubscribe = true;
|
||||
let subscriptionId: string | null = null;
|
||||
const entries: FetchAgentsEntry[] = [];
|
||||
|
||||
while (true) {
|
||||
const payload = await input.client.fetchAgents({
|
||||
@@ -242,7 +253,8 @@ async function fetchCurrentAgentDirectory(
|
||||
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
|
||||
});
|
||||
|
||||
entries.push(...payload.entries);
|
||||
if (!input.isCurrent()) throw new AgentDirectoryRefreshSupersededError();
|
||||
input.transaction.entries.push(...payload.entries);
|
||||
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
|
||||
includeSubscribe = false;
|
||||
|
||||
@@ -257,7 +269,11 @@ async function fetchCurrentAgentDirectory(
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return { entries, subscriptionId };
|
||||
return {
|
||||
entries: input.transaction.entries,
|
||||
deltas: input.transaction.deltas,
|
||||
subscriptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
||||
@@ -1393,6 +1409,28 @@ function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void
|
||||
map.set(newKey, value);
|
||||
}
|
||||
|
||||
interface AgentDirectoryTransaction {
|
||||
id: symbol;
|
||||
client: DaemonClient;
|
||||
clientGeneration: number;
|
||||
entries: FetchAgentsEntry[];
|
||||
deltas: AgentDirectoryDelta[];
|
||||
}
|
||||
|
||||
interface AgentDirectoryRefreshInput {
|
||||
serverId: string;
|
||||
filter?: FetchAgentsOptions["filter"];
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}
|
||||
|
||||
interface AgentDirectoryRefreshResult {
|
||||
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
|
||||
subscriptionId: string | null;
|
||||
}
|
||||
|
||||
class AgentDirectoryRefreshSupersededError extends Error {}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private serverListeners = new Map<string, Set<() => void>>();
|
||||
@@ -1406,6 +1444,11 @@ export class HostRuntimeStore {
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private agentDirectoryTransactions = new Map<string, AgentDirectoryTransaction>();
|
||||
private agentDirectoryListeners = new Map<
|
||||
string,
|
||||
{ client: DaemonClient; generation: number; unsubscribe: () => void }
|
||||
>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootStarted = false;
|
||||
private storage: HostRuntimeStorage;
|
||||
@@ -1926,6 +1969,9 @@ export class HostRuntimeStore {
|
||||
this.controllers.delete(serverId);
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.agentDirectoryBootstrapInFlight.delete(serverId);
|
||||
this.agentDirectoryTransactions.delete(serverId);
|
||||
this.agentDirectoryListeners.get(serverId)?.unsubscribe();
|
||||
this.agentDirectoryListeners.delete(serverId);
|
||||
void controller.stop();
|
||||
this.emit(serverId);
|
||||
}
|
||||
@@ -1980,6 +2026,11 @@ export class HostRuntimeStore {
|
||||
return;
|
||||
}
|
||||
const snapshot = controller.getSnapshot();
|
||||
this.installAgentDirectoryListener({
|
||||
serverId,
|
||||
client: snapshot.client,
|
||||
generation: snapshot.clientGeneration,
|
||||
});
|
||||
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
||||
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
||||
const didTransitionOnline =
|
||||
@@ -2006,14 +2057,11 @@ export class HostRuntimeStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrap = Promise.resolve()
|
||||
.then(() =>
|
||||
this.refreshAgentDirectory({
|
||||
serverId,
|
||||
subscribe: { subscriptionId: `app:${serverId}` },
|
||||
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
|
||||
}),
|
||||
)
|
||||
const bootstrap = this.refreshAgentDirectory({
|
||||
serverId,
|
||||
subscribe: { subscriptionId: `app:${serverId}` },
|
||||
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] agent directory bootstrap failed", {
|
||||
@@ -2031,6 +2079,74 @@ export class HostRuntimeStore {
|
||||
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
|
||||
}
|
||||
|
||||
private installAgentDirectoryListener(input: {
|
||||
serverId: string;
|
||||
client: DaemonClient | null;
|
||||
generation: number;
|
||||
}): void {
|
||||
const current = this.agentDirectoryListeners.get(input.serverId);
|
||||
if (current?.client === input.client && current.generation === input.generation) return;
|
||||
current?.unsubscribe();
|
||||
this.agentDirectoryListeners.delete(input.serverId);
|
||||
this.agentDirectoryTransactions.delete(input.serverId);
|
||||
if (!input.client) return;
|
||||
const client = input.client;
|
||||
const unsubscribe = client.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
const installed = this.agentDirectoryListeners.get(input.serverId);
|
||||
if (installed?.client !== client || installed.generation !== input.generation) return;
|
||||
const transaction = this.agentDirectoryTransactions.get(input.serverId);
|
||||
if (transaction?.client === client && transaction.clientGeneration === input.generation) {
|
||||
transaction.deltas.push(message.payload);
|
||||
return;
|
||||
}
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
if (session?.client !== client || session.clientGeneration !== input.generation) return;
|
||||
this.applyAgentDirectoryDelta(input.serverId, message.payload);
|
||||
});
|
||||
this.agentDirectoryListeners.set(input.serverId, {
|
||||
client,
|
||||
generation: input.generation,
|
||||
unsubscribe,
|
||||
});
|
||||
}
|
||||
|
||||
private applyAgentDirectoryDelta(serverId: string, delta: AgentDirectoryDelta): void {
|
||||
const result = applyAgentDirectoryDelta({ serverId, delta });
|
||||
if (result.stoppedRunning) {
|
||||
this.drainQueuedAgentMessage(serverId, result.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private drainQueuedAgentMessage(serverId: string, agentId: string): void {
|
||||
const store = useSessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
const queue = session?.queuedMessages.get(agentId);
|
||||
if (!session?.client || !queue?.length) return;
|
||||
const [next, ...rest] = queue;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments);
|
||||
store.setQueuedMessages(serverId, (current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(agentId, rest);
|
||||
return updated;
|
||||
});
|
||||
void encodeImages(wirePayload.images)
|
||||
.then((images) =>
|
||||
session.client?.sendAgentMessage(agentId, next.text, {
|
||||
messageId: next.id,
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
attachments: wirePayload.attachments,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[HostRuntime] failed to drain queued agent message", {
|
||||
serverId,
|
||||
agentId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||
}
|
||||
@@ -2095,15 +2211,9 @@ export class HostRuntimeStore {
|
||||
).then(() => undefined);
|
||||
}
|
||||
|
||||
async refreshAgentDirectory(input: {
|
||||
serverId: string;
|
||||
filter?: FetchAgentsOptions["filter"];
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}): Promise<{
|
||||
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
|
||||
subscriptionId: string | null;
|
||||
}> {
|
||||
async refreshAgentDirectory(
|
||||
input: AgentDirectoryRefreshInput,
|
||||
): Promise<AgentDirectoryRefreshResult> {
|
||||
const controller = this.controllers.get(input.serverId);
|
||||
if (!controller) {
|
||||
throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
|
||||
@@ -2114,43 +2224,186 @@ export class HostRuntimeStore {
|
||||
throw new Error(`Host ${input.serverId} is not connected`);
|
||||
}
|
||||
|
||||
const transaction: AgentDirectoryTransaction = {
|
||||
id: Symbol("agent directory refresh"),
|
||||
client,
|
||||
clientGeneration: snapshot.clientGeneration,
|
||||
entries: [],
|
||||
deltas: [],
|
||||
};
|
||||
this.agentDirectoryTransactions.set(input.serverId, transaction);
|
||||
|
||||
const hasMatchingSession = () => {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
return (
|
||||
session?.client === client && session.clientGeneration === transaction.clientGeneration
|
||||
);
|
||||
};
|
||||
if (!hasMatchingSession()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const unsubscribeStore = useSessionStore.subscribe((state) => {
|
||||
const session = state.sessions[input.serverId];
|
||||
if (
|
||||
session?.client === client &&
|
||||
session.clientGeneration === transaction.clientGeneration
|
||||
) {
|
||||
unsubscribeStore();
|
||||
unsubscribeController();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const unsubscribeController = controller.subscribe(() => {
|
||||
if (controller.getClient() !== client) {
|
||||
unsubscribeStore();
|
||||
unsubscribeController();
|
||||
reject(new Error(`Host ${input.serverId} client changed before directory bootstrap`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
controller.markAgentDirectorySyncLoading();
|
||||
try {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
|
||||
const result = await fetchLegacyDaemonWorkspaceDirectory({
|
||||
return await this.refreshLegacyAgentDirectory({
|
||||
input,
|
||||
controller,
|
||||
client,
|
||||
serverId: input.serverId,
|
||||
subscribe: input.subscribe,
|
||||
page: input.page,
|
||||
transaction,
|
||||
hasMatchingSession,
|
||||
});
|
||||
controller.markAgentDirectorySyncReady();
|
||||
return {
|
||||
agents: result.agents,
|
||||
subscriptionId: result.subscriptionId,
|
||||
};
|
||||
}
|
||||
|
||||
const directory = await fetchCurrentAgentDirectory({
|
||||
return await this.refreshCurrentAgentDirectory({
|
||||
input,
|
||||
controller,
|
||||
client,
|
||||
filter: input.filter,
|
||||
subscribe: input.subscribe,
|
||||
page: input.page,
|
||||
transaction,
|
||||
hasMatchingSession,
|
||||
});
|
||||
|
||||
const { agents } = replaceFetchedAgentDirectory({
|
||||
serverId: input.serverId,
|
||||
entries: directory.entries,
|
||||
});
|
||||
|
||||
controller.markAgentDirectorySyncReady();
|
||||
return {
|
||||
agents,
|
||||
subscriptionId: directory.subscriptionId,
|
||||
};
|
||||
} catch (error) {
|
||||
controller.markAgentDirectorySyncError(toErrorMessage(error));
|
||||
if (!(error instanceof AgentDirectoryRefreshSupersededError)) {
|
||||
controller.markAgentDirectorySyncError(toErrorMessage(error));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id) {
|
||||
this.agentDirectoryTransactions.delete(input.serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshLegacyAgentDirectory(context: {
|
||||
input: AgentDirectoryRefreshInput;
|
||||
controller: HostRuntimeController;
|
||||
client: DaemonClient;
|
||||
transaction: AgentDirectoryTransaction;
|
||||
hasMatchingSession: () => boolean;
|
||||
}): Promise<AgentDirectoryRefreshResult> {
|
||||
const { input, controller, client, transaction, hasMatchingSession } = context;
|
||||
const directory = await readLegacyDaemonWorkspaceDirectory({
|
||||
client,
|
||||
subscribe: input.subscribe,
|
||||
page: input.page,
|
||||
});
|
||||
if (
|
||||
!directory ||
|
||||
this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id ||
|
||||
!hasMatchingSession()
|
||||
) {
|
||||
throw new AgentDirectoryRefreshSupersededError();
|
||||
}
|
||||
const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map();
|
||||
const stampedSnapshot = stampLegacyWorkspaceIds(directory.entries);
|
||||
const reconciled = reconcileAgentDirectory({
|
||||
previous,
|
||||
snapshot: stampedSnapshot,
|
||||
deltas: transaction.deltas,
|
||||
});
|
||||
const result = replaceLegacyDaemonWorkspaceDirectory({
|
||||
serverId: input.serverId,
|
||||
entries: reconciled.entries,
|
||||
});
|
||||
this.applyAgentDirectoryCommitSideEffects({
|
||||
serverId: input.serverId,
|
||||
previous,
|
||||
directory: {
|
||||
entries: stampedSnapshot,
|
||||
deltas: transaction.deltas,
|
||||
subscriptionId: directory.subscriptionId,
|
||||
},
|
||||
stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds,
|
||||
});
|
||||
this.agentDirectoryBootstrapInFlight.delete(input.serverId);
|
||||
controller.markAgentDirectorySyncReady();
|
||||
return { agents: result.agents, subscriptionId: directory.subscriptionId };
|
||||
}
|
||||
|
||||
private async refreshCurrentAgentDirectory(context: {
|
||||
input: AgentDirectoryRefreshInput;
|
||||
controller: HostRuntimeController;
|
||||
client: DaemonClient;
|
||||
transaction: AgentDirectoryTransaction;
|
||||
hasMatchingSession: () => boolean;
|
||||
}): Promise<AgentDirectoryRefreshResult> {
|
||||
const { input, controller, client, transaction, hasMatchingSession } = context;
|
||||
const directory = await fetchCurrentAgentDirectory({
|
||||
client,
|
||||
filter: input.filter,
|
||||
subscribe: input.subscribe,
|
||||
page: input.page,
|
||||
transaction,
|
||||
isCurrent: () =>
|
||||
this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id &&
|
||||
controller.getSnapshot().client === client &&
|
||||
controller.getSnapshot().clientGeneration === transaction.clientGeneration,
|
||||
});
|
||||
if (
|
||||
this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id ||
|
||||
controller.getSnapshot().client !== client ||
|
||||
controller.getSnapshot().clientGeneration !== transaction.clientGeneration ||
|
||||
!hasMatchingSession()
|
||||
) {
|
||||
throw new AgentDirectoryRefreshSupersededError();
|
||||
}
|
||||
const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map();
|
||||
const reconciled = reconcileAgentDirectory({
|
||||
previous,
|
||||
snapshot: directory.entries,
|
||||
deltas: directory.deltas,
|
||||
});
|
||||
const { agents } = replaceFetchedAgentDirectory({
|
||||
serverId: input.serverId,
|
||||
entries: reconciled.entries,
|
||||
});
|
||||
this.applyAgentDirectoryCommitSideEffects({
|
||||
serverId: input.serverId,
|
||||
previous,
|
||||
directory,
|
||||
stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds,
|
||||
});
|
||||
this.agentDirectoryBootstrapInFlight.delete(input.serverId);
|
||||
controller.markAgentDirectorySyncReady();
|
||||
return { agents, subscriptionId: directory.subscriptionId };
|
||||
}
|
||||
|
||||
private applyAgentDirectoryCommitSideEffects(input: {
|
||||
serverId: string;
|
||||
previous: ReadonlyMap<string, Agent>;
|
||||
directory: AgentDirectoryFetchResult;
|
||||
stoppedRunningAgentIds: string[];
|
||||
}): void {
|
||||
const snapshotAgentIds = new Set(input.directory.entries.map((entry) => entry.agent.id));
|
||||
for (const agentId of input.previous.keys()) {
|
||||
if (!snapshotAgentIds.has(agentId)) {
|
||||
applyAgentDirectoryDelta({ serverId: input.serverId, delta: { kind: "remove", agentId } });
|
||||
}
|
||||
}
|
||||
for (const delta of input.directory.deltas) {
|
||||
applyAgentDirectoryDelta({ serverId: input.serverId, delta });
|
||||
}
|
||||
for (const agentId of input.stoppedRunningAgentIds) {
|
||||
this.drainQueuedAgentMessage(input.serverId, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { selectVisibleAgentIds } from "./visible-agent-ids";
|
||||
|
||||
test("selects only the active agent tab in every visible pane", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "left",
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: "pane", pane: { id: "left", tabIds: ["a", "hidden"], focusedTabId: "a" } },
|
||||
{ kind: "pane", pane: { id: "right", tabIds: ["b"], focusedTabId: "b" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "hidden", target: { kind: "agent", agentId: "agent-hidden" }, createdAt: 2 },
|
||||
{ tabId: "b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 3 },
|
||||
];
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, routeFocused: true, focusedPaneOnly: false }),
|
||||
).toEqual(["agent-a", "agent-b"]);
|
||||
});
|
||||
|
||||
test("route blur publishes no viewed agents", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
];
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, routeFocused: false, focusedPaneOnly: false }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("compact and focus modes contribute only the focused pane", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "right",
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: "pane", pane: { id: "left", tabIds: ["a"], focusedTabId: "a" } },
|
||||
{ kind: "pane", pane: { id: "right", tabIds: ["b"], focusedTabId: "b" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
];
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, routeFocused: true, focusedPaneOnly: true }),
|
||||
).toEqual(["agent-b"]);
|
||||
});
|
||||
|
||||
test("pane retargeting replaces the viewed agent and duplicate panes collapse to one ID", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "left",
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "root",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: "pane", pane: { id: "left", tabIds: ["active"], focusedTabId: "active" } },
|
||||
{ kind: "pane", pane: { id: "right", tabIds: ["duplicate"], focusedTabId: "duplicate" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const duplicateTabs: WorkspaceTab[] = [
|
||||
{ tabId: "active", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "duplicate", target: { kind: "agent", agentId: "agent-a" }, createdAt: 2 },
|
||||
];
|
||||
const retargetedTabs: WorkspaceTab[] = [
|
||||
{ tabId: "active", target: { kind: "agent", agentId: "agent-b" }, createdAt: 1 },
|
||||
{ tabId: "duplicate", target: { kind: "agent", agentId: "agent-a" }, createdAt: 2 },
|
||||
];
|
||||
|
||||
expect({
|
||||
duplicate: selectVisibleAgentIds({
|
||||
layout,
|
||||
tabs: duplicateTabs,
|
||||
routeFocused: true,
|
||||
focusedPaneOnly: false,
|
||||
}),
|
||||
retargeted: selectVisibleAgentIds({
|
||||
layout,
|
||||
tabs: retargetedTabs,
|
||||
routeFocused: true,
|
||||
focusedPaneOnly: false,
|
||||
}),
|
||||
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||
|
||||
export function selectVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
}): string[] {
|
||||
if (!input.routeFocused || !input.layout) {
|
||||
return [];
|
||||
}
|
||||
const panes = input.focusedPaneOnly
|
||||
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
|
||||
: collectAllPanes(input.layout.root);
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
panes.flatMap((pane) => {
|
||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
||||
.target;
|
||||
return target?.kind === "agent" ? [target.agentId] : [];
|
||||
}),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/workspace-tabs/identity";
|
||||
import { selectVisibleAgentIds } from "./visible-agent-ids";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeClient,
|
||||
@@ -2013,6 +2014,31 @@ function WorkspaceScreenContent({
|
||||
}),
|
||||
[uiTabs, workspaceLayout],
|
||||
);
|
||||
const viewedTimelineSync = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null,
|
||||
);
|
||||
const visibleAgentIds = useMemo(
|
||||
() =>
|
||||
selectVisibleAgentIds({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: isMobile || isFocusModeEnabled,
|
||||
}),
|
||||
[isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
}
|
||||
viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds);
|
||||
}, [persistenceKey, viewedTimelineSync, visibleAgentIds]);
|
||||
useEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
}
|
||||
return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []);
|
||||
}, [persistenceKey, viewedTimelineSync]);
|
||||
const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
const focusedPaneAgentId = useMemo(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import equal from "fast-deep-equal";
|
||||
import { create } from "zustand";
|
||||
import { subscribeWithSelector } from "zustand/middleware";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import {
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
@@ -325,6 +326,8 @@ export interface SessionState {
|
||||
|
||||
// Daemon client (immutable reference)
|
||||
client: DaemonClient | null;
|
||||
clientGeneration: number;
|
||||
viewedTimelineSync: ViewedTimelineUiBridge | null;
|
||||
|
||||
// Server metadata (from server_info handshake)
|
||||
serverInfo: DaemonServerInfo | null;
|
||||
@@ -390,10 +393,11 @@ interface SessionStoreState {
|
||||
// Action types
|
||||
interface SessionStoreActions {
|
||||
// Session management
|
||||
initializeSession: (serverId: string, client: DaemonClient) => void;
|
||||
initializeSession: (serverId: string, client: DaemonClient, clientGeneration?: number) => void;
|
||||
clearSession: (serverId: string) => void;
|
||||
getSession: (serverId: string) => SessionState | undefined;
|
||||
updateSessionClient: (serverId: string, client: DaemonClient) => void;
|
||||
updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void;
|
||||
setViewedTimelineSync: (serverId: string, sync: ViewedTimelineUiBridge | null) => void;
|
||||
updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void;
|
||||
|
||||
// Audio state
|
||||
@@ -531,10 +535,16 @@ type SessionStore = SessionStoreState & SessionStoreActions;
|
||||
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
|
||||
|
||||
// Helper to create initial session state
|
||||
function createInitialSessionState(serverId: string, client: DaemonClient): SessionState {
|
||||
function createInitialSessionState(
|
||||
serverId: string,
|
||||
client: DaemonClient,
|
||||
clientGeneration = 0,
|
||||
): SessionState {
|
||||
return {
|
||||
serverId,
|
||||
client,
|
||||
clientGeneration,
|
||||
viewedTimelineSync: null,
|
||||
serverInfo: null,
|
||||
hasHydratedAgents: false,
|
||||
hasHydratedWorkspaces: false,
|
||||
@@ -637,7 +647,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
agentLastActivity: new Map(),
|
||||
|
||||
// Session management
|
||||
initializeSession: (serverId, client) => {
|
||||
initializeSession: (serverId, client, clientGeneration) => {
|
||||
set((prev) => {
|
||||
if (prev.sessions[serverId]) {
|
||||
return prev;
|
||||
@@ -646,7 +656,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: createInitialSessionState(serverId, client),
|
||||
[serverId]: createInitialSessionState(serverId, client, clientGeneration),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -686,7 +696,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
updateSessionClient: (serverId, client) => {
|
||||
updateSessionClient: (serverId, client, clientGeneration = 0) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
|
||||
@@ -694,7 +704,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (session.client === client) {
|
||||
if (session.client === client && session.clientGeneration === clientGeneration) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
@@ -705,12 +715,29 @@ export const useSessionStore = create<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
client,
|
||||
clientGeneration,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setViewedTimelineSync: (serverId, viewedTimelineSync) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || session.viewedTimelineSync === viewedTimelineSync) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, viewedTimelineSync },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateSessionServerInfo: (serverId, info) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
|
||||
@@ -1160,7 +1160,7 @@ export interface CreateSessionAgentStreamReducerQueueInput {
|
||||
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
|
||||
) => void;
|
||||
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
|
||||
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
}
|
||||
|
||||
function scheduleAgentStreamReducerFlush(callback: () => void): number {
|
||||
@@ -1174,13 +1174,8 @@ function cancelAgentStreamReducerFlush(id: number) {
|
||||
export function createSessionAgentStreamReducerQueue(
|
||||
input: CreateSessionAgentStreamReducerQueueInput,
|
||||
): AgentStreamReducerQueue {
|
||||
const {
|
||||
serverId,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
setAgents,
|
||||
requestCanonicalCatchUp,
|
||||
} = input;
|
||||
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } =
|
||||
input;
|
||||
|
||||
return createAgentStreamReducerQueue({
|
||||
getSnapshot: (agentId) => {
|
||||
@@ -1258,7 +1253,7 @@ export function createSessionAgentStreamReducerQueue(
|
||||
handleSideEffects: (agentId, sideEffects) => {
|
||||
for (const effect of sideEffects) {
|
||||
if (effect.type === "catch_up") {
|
||||
requestCanonicalCatchUp(agentId, effect.cursor);
|
||||
recoverTimelineGap(agentId, effect.cursor);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isTimelineCatchUpComplete,
|
||||
planInitialAgentTimelineSync,
|
||||
planResumeTimelineSync,
|
||||
planTimelineCatchUpFollowUp,
|
||||
planTimelineOlderFetch,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
@@ -70,31 +69,7 @@ describe("timeline sync planning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("catch-up keeps paging while the daemon reports newer rows", () => {
|
||||
const plan = planTimelineCatchUpFollowUp({
|
||||
direction: "after",
|
||||
hasNewer: true,
|
||||
endCursor: { epoch: "epoch-1", seq: 200 },
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 200 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
});
|
||||
});
|
||||
|
||||
test("catch-up finishes when the daemon reports no newer rows", () => {
|
||||
const plan = planTimelineCatchUpFollowUp({
|
||||
direction: "after",
|
||||
hasNewer: false,
|
||||
endCursor: { epoch: "epoch-1", seq: 200 },
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(plan).toBeNull();
|
||||
expect(isTimelineCatchUpComplete({ direction: "after", hasNewer: false, error: null })).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -87,19 +87,6 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function planTimelineCatchUpFollowUp(input: {
|
||||
direction: "tail" | "before" | "after";
|
||||
hasNewer: boolean;
|
||||
endCursor: TimelineSyncCursor | null;
|
||||
error: string | null;
|
||||
}): ProjectedTimelineAfterFetchPlan | null {
|
||||
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return planTimelineCatchUpAfter(input.endCursor);
|
||||
}
|
||||
|
||||
export function isTimelineCatchUpComplete(input: {
|
||||
direction: "tail" | "before" | "after";
|
||||
hasNewer: boolean;
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
interface MembershipRequest {
|
||||
agentIds: string[];
|
||||
succeed(): void;
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
interface TimelineFetch {
|
||||
agentId: string;
|
||||
request: ProjectedTimelineForwardFetchPlan;
|
||||
respond(input: { hasNewer: boolean; seq?: number }): void;
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
class TimelineWorld {
|
||||
readonly errors: string[] = [];
|
||||
readonly sync = createViewedTimelineSync({
|
||||
setSubscription: async (agentIds) => {
|
||||
const result = deferred<void>();
|
||||
this.memberships.push({
|
||||
agentIds,
|
||||
succeed: () => result.resolve(),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
this.releaseMembershipWaiter();
|
||||
return result.promise;
|
||||
},
|
||||
readCursor: (agentId) => this.cursors.get(agentId),
|
||||
fetchPage: async (agentId, request) => {
|
||||
const result = deferred<{
|
||||
hasNewer: boolean;
|
||||
endCursor: { epoch: string; seq: number } | null;
|
||||
}>();
|
||||
this.fetches.push({
|
||||
agentId,
|
||||
request,
|
||||
respond: ({ hasNewer, seq = 1 }) =>
|
||||
result.resolve({
|
||||
hasNewer,
|
||||
endCursor: { epoch: `epoch-${agentId}`, seq },
|
||||
}),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
this.releaseFetchWaiters();
|
||||
return result.promise;
|
||||
},
|
||||
reportError: (error) => {
|
||||
this.errors.push(error instanceof Error ? error.message : String(error));
|
||||
const waiter = this.errorWaiters.shift();
|
||||
if (waiter) waiter(this.errors.at(-1) ?? "");
|
||||
},
|
||||
scheduleRetry: (retry) => {
|
||||
this.retries.push(retry);
|
||||
const waiter = this.retryWaiters.shift();
|
||||
if (waiter) waiter(this.retries.shift()!);
|
||||
return () => {
|
||||
const index = this.retries.indexOf(retry);
|
||||
if (index >= 0) this.retries.splice(index, 1);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
private readonly memberships: MembershipRequest[] = [];
|
||||
private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = [];
|
||||
private readonly fetches: TimelineFetch[] = [];
|
||||
private readonly fetchWaiters: Array<{
|
||||
agentId: string;
|
||||
resolve(fetch: TimelineFetch): void;
|
||||
}> = [];
|
||||
private readonly cursors = new Map<string, { epoch: string; startSeq: number; endSeq: number }>();
|
||||
private readonly errorWaiters: Array<(message: string) => void> = [];
|
||||
private readonly retries: Array<() => void> = [];
|
||||
private readonly retryWaiters: Array<(retry: () => void) => void> = [];
|
||||
|
||||
setCursor(agentId: string, endSeq: number): void {
|
||||
this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq });
|
||||
}
|
||||
|
||||
nextMembership(): Promise<MembershipRequest> {
|
||||
const request = this.memberships.shift();
|
||||
if (request) return Promise.resolve(request);
|
||||
return new Promise((resolve) => this.membershipWaiters.push(resolve));
|
||||
}
|
||||
|
||||
nextFetch(agentId: string): Promise<TimelineFetch> {
|
||||
const index = this.fetches.findIndex((fetch) => fetch.agentId === agentId);
|
||||
if (index >= 0) return Promise.resolve(this.fetches.splice(index, 1)[0]);
|
||||
return new Promise((resolve) => this.fetchWaiters.push({ agentId, resolve }));
|
||||
}
|
||||
|
||||
expectNoPendingMembership(): void {
|
||||
expect(this.memberships).toEqual([]);
|
||||
}
|
||||
|
||||
expectNoPendingFetch(): void {
|
||||
expect(this.fetches).toEqual([]);
|
||||
}
|
||||
|
||||
nextError(): Promise<string> {
|
||||
const message = this.errors.at(-1);
|
||||
if (message) return Promise.resolve(message);
|
||||
return new Promise((resolve) => this.errorWaiters.push(resolve));
|
||||
}
|
||||
|
||||
nextRetry(): Promise<() => void> {
|
||||
const retry = this.retries.shift();
|
||||
if (retry) return Promise.resolve(retry);
|
||||
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
||||
}
|
||||
|
||||
private releaseMembershipWaiter(): void {
|
||||
const waiter = this.membershipWaiters.shift();
|
||||
if (!waiter) return;
|
||||
const request = this.memberships.shift();
|
||||
if (request) waiter(request);
|
||||
}
|
||||
|
||||
private releaseFetchWaiters(): void {
|
||||
for (let waiterIndex = this.fetchWaiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) {
|
||||
const waiter = this.fetchWaiters[waiterIndex];
|
||||
const index = this.fetches.findIndex((fetch) => fetch.agentId === waiter.agentId);
|
||||
if (index < 0) continue;
|
||||
this.fetchWaiters.splice(waiterIndex, 1);
|
||||
waiter.resolve(this.fetches.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const firstPage = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
|
||||
firstPage.respond({ hasNewer: true, seq: 5 });
|
||||
const secondPage = await world.nextFetch("agent-a");
|
||||
secondPage.respond({ hasNewer: false });
|
||||
|
||||
expect(secondPage.request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 5 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
});
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("all acknowledged agents begin catch-up independently", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
expect(membership.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
});
|
||||
|
||||
test("membership changes during acknowledgement never catch up the stale set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const staleMembership = await world.nextMembership();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
staleMembership.succeed();
|
||||
const currentMembership = await world.nextMembership();
|
||||
currentMembership.succeed();
|
||||
const agentB = await world.nextFetch("agent-b");
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({
|
||||
stale: ["agent-a"],
|
||||
current: ["agent-b"],
|
||||
});
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("removing one agent during paging cancels only that agent", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
|
||||
const initialMembership = await world.nextMembership();
|
||||
initialMembership.succeed();
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const replacement = await world.nextMembership();
|
||||
agentA.respond({ hasNewer: true, seq: 4 });
|
||||
agentB.respond({ hasNewer: true, seq: 7 });
|
||||
const agentBNext = await world.nextFetch("agent-b");
|
||||
replacement.succeed();
|
||||
agentBNext.respond({ hasNewer: false });
|
||||
|
||||
expect(replacement.agentIds).toEqual(["agent-b"]);
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const firstMembership = await world.nextMembership();
|
||||
firstMembership.succeed();
|
||||
const stalePage = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.setConnected(false);
|
||||
stalePage.respond({ hasNewer: true, seq: 8 });
|
||||
world.sync.setConnected(true);
|
||||
const restoredMembership = await world.nextMembership();
|
||||
restoredMembership.succeed();
|
||||
const restoredPage = await world.nextFetch("agent-a");
|
||||
restoredPage.respond({ hasNewer: false });
|
||||
|
||||
expect(restoredMembership.agentIds).toEqual(["agent-a"]);
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
|
||||
world.sync.setConnected(true);
|
||||
const combined = await world.nextMembership();
|
||||
combined.succeed();
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("left-route", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
||||
const remaining = await world.nextMembership();
|
||||
remaining.succeed();
|
||||
|
||||
expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({
|
||||
combined: ["agent-a", "agent-b"],
|
||||
remaining: ["agent-b"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const failed = await world.nextFetch("agent-a");
|
||||
failed.fail("timeline unavailable");
|
||||
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
|
||||
retryCatchUp();
|
||||
const retry = await world.nextFetch("agent-a");
|
||||
retry.respond({ hasNewer: false });
|
||||
|
||||
expect({ error, retryDirection: retry.request.direction }).toEqual({
|
||||
error: "timeline unavailable",
|
||||
retryDirection: "tail",
|
||||
});
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("gap recovery supersedes completed catch-up and pages through the current tail", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
initial.respond({ hasNewer: false });
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
const gapPage = await world.nextFetch("agent-a");
|
||||
gapPage.respond({ hasNewer: true, seq: 15 });
|
||||
const finalPage = await world.nextFetch("agent-a");
|
||||
finalPage.respond({ hasNewer: false });
|
||||
|
||||
expect([gapPage.request, finalPage.request]).toEqual([
|
||||
{
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 10 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
},
|
||||
{
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 15 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("membership failure autonomously retries without another visibility declaration", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
|
||||
retryMembership();
|
||||
const retry = await world.nextMembership();
|
||||
retry.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({
|
||||
error: "subscription unavailable",
|
||||
failed: ["agent-a"],
|
||||
retry: ["agent-a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("background sends an empty set and foreground restores visible membership", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const initial = await world.nextMembership();
|
||||
initial.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.setActive(false);
|
||||
const background = await world.nextMembership();
|
||||
background.succeed();
|
||||
world.sync.setActive(true);
|
||||
const foreground = await world.nextMembership();
|
||||
foreground.succeed();
|
||||
const resumedCatchUp = await world.nextFetch("agent-a");
|
||||
resumedCatchUp.respond({ hasNewer: false });
|
||||
|
||||
expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({
|
||||
background: [],
|
||||
foreground: ["agent-a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("stale membership retry cannot overwrite a newer effective set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const staleRetry = await world.nextRetry();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const current = await world.nextMembership();
|
||||
staleRetry();
|
||||
current.succeed();
|
||||
const catchUp = await world.nextFetch("agent-b");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(current.agentIds).toEqual(["agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("membership retry cannot run while disconnected", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const disconnectedRetry = await world.nextRetry();
|
||||
|
||||
world.sync.setConnected(false);
|
||||
disconnectedRetry();
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.setConnected(true);
|
||||
const restored = await world.nextMembership();
|
||||
restored.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(restored.agentIds).toEqual(["agent-a"]);
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import {
|
||||
planResumeTimelineSync,
|
||||
planTimelineCatchUpAfter,
|
||||
type ProjectedTimelineForwardFetchPlan,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
interface TimelinePageResult {
|
||||
hasNewer: boolean;
|
||||
endCursor: { epoch: string; seq: number } | null;
|
||||
}
|
||||
|
||||
interface ViewedTimelineSyncPorts {
|
||||
setSubscription(agentIds: string[]): Promise<void>;
|
||||
readCursor(agentId: string): AgentTimelineCursorState | undefined;
|
||||
fetchPage(
|
||||
agentId: string,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<TimelinePageResult>;
|
||||
reportError(error: unknown): void;
|
||||
scheduleRetry(retry: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineUiBridge {
|
||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
setActive(active: boolean): void;
|
||||
setConnected(connected: boolean): void;
|
||||
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type CatchUpStatus = "running" | "complete" | "error";
|
||||
|
||||
interface CatchUpState {
|
||||
generation: number;
|
||||
status: CatchUpStatus;
|
||||
cancelRetry?: () => void;
|
||||
}
|
||||
|
||||
function normalizeAgentIds(agentIds: string[]): string[] {
|
||||
return [...new Set(agentIds)].filter(Boolean).sort();
|
||||
}
|
||||
|
||||
function sameAgentIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((agentId, index) => agentId === right[index]);
|
||||
}
|
||||
|
||||
export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync {
|
||||
const sources = new Map<string, string[]>();
|
||||
const catchUps = new Map<string, CatchUpState>();
|
||||
const catchUpGenerations = new Map<string, number>();
|
||||
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
let active = true;
|
||||
let connected = false;
|
||||
let disposed = false;
|
||||
let desired: string[] = [];
|
||||
let acknowledged: string[] = [];
|
||||
let membershipGeneration = 0;
|
||||
let reconciling = false;
|
||||
let reconcileRequested = false;
|
||||
let membershipNeedsRetry = false;
|
||||
let cancelMembershipRetry: (() => void) | null = null;
|
||||
|
||||
const effectiveAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
|
||||
|
||||
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||
|
||||
const cancelCatchUp = (agentId: string) => {
|
||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||
catchUps.get(agentId)?.cancelRetry?.();
|
||||
catchUps.delete(agentId);
|
||||
pendingGaps.delete(agentId);
|
||||
};
|
||||
|
||||
const fetchUntilCurrent = async (
|
||||
agentId: string,
|
||||
generation: number,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<void> => {
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
!isDesired(agentId) ||
|
||||
!isAcknowledged(agentId) ||
|
||||
catchUps.get(agentId)?.generation !== generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await ports.fetchPage(agentId, request);
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
!isDesired(agentId) ||
|
||||
!isAcknowledged(agentId) ||
|
||||
catchUps.get(agentId)?.generation !== generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (page.hasNewer && page.endCursor) {
|
||||
await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor));
|
||||
return;
|
||||
}
|
||||
if (page.hasNewer) {
|
||||
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
|
||||
}
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
const cancelRetry = ports.scheduleRetry(() => {
|
||||
const current = catchUps.get(agentId);
|
||||
if (current?.generation !== generation || current.status !== "error") return;
|
||||
startCatchUp(agentId);
|
||||
});
|
||||
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
||||
ports.reportError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startCatchUp = (
|
||||
agentId: string,
|
||||
options: {
|
||||
request?: ProjectedTimelineForwardFetchPlan;
|
||||
supersede?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const { request, supersede = false } = options;
|
||||
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) {
|
||||
if (request) pendingGaps.set(agentId, request);
|
||||
return;
|
||||
}
|
||||
const current = catchUps.get(agentId);
|
||||
if (!supersede && (current?.status === "running" || current?.status === "complete")) {
|
||||
return;
|
||||
}
|
||||
current?.cancelRetry?.();
|
||||
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
|
||||
catchUpGenerations.set(agentId, generation);
|
||||
catchUps.set(agentId, { generation, status: "running" });
|
||||
pendingGaps.delete(agentId);
|
||||
const nextRequest = request ?? planResumeTimelineSync({ cursor: ports.readCursor(agentId) });
|
||||
void fetchUntilCurrent(agentId, generation, nextRequest);
|
||||
};
|
||||
|
||||
const startAcknowledgedCatchUps = () => {
|
||||
for (const agentId of acknowledged) {
|
||||
const gap = pendingGaps.get(agentId);
|
||||
startCatchUp(agentId, { request: gap, supersede: Boolean(gap) });
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileLatestMembership = async (): Promise<void> => {
|
||||
if (disposed || !connected) return;
|
||||
const generation = membershipGeneration;
|
||||
const requested = desired;
|
||||
if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return;
|
||||
membershipNeedsRetry = false;
|
||||
try {
|
||||
await ports.setSubscription(requested);
|
||||
} catch (error) {
|
||||
membershipNeedsRetry = true;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = ports.scheduleRetry(() => {
|
||||
cancelMembershipRetry = null;
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
membershipGeneration !== generation ||
|
||||
!sameAgentIds(desired, requested)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void reconcileMembership();
|
||||
});
|
||||
ports.reportError(error);
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
if (disposed || !connected) return;
|
||||
acknowledged = requested;
|
||||
if (generation !== membershipGeneration) {
|
||||
await reconcileLatestMembership();
|
||||
return;
|
||||
}
|
||||
startAcknowledgedCatchUps();
|
||||
if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership();
|
||||
};
|
||||
|
||||
const reconcileMembership = async () => {
|
||||
if (reconciling) {
|
||||
reconcileRequested = true;
|
||||
return;
|
||||
}
|
||||
if (disposed || !connected) return;
|
||||
reconciling = true;
|
||||
try {
|
||||
await reconcileLatestMembership();
|
||||
} finally {
|
||||
reconciling = false;
|
||||
if (reconcileRequested && !disposed && connected) {
|
||||
reconcileRequested = false;
|
||||
void reconcileMembership();
|
||||
} else if (
|
||||
!disposed &&
|
||||
connected &&
|
||||
!membershipNeedsRetry &&
|
||||
!sameAgentIds(desired, acknowledged)
|
||||
) {
|
||||
void reconcileMembership();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const retryFailedCatchUps = () => {
|
||||
for (const agentId of acknowledged) {
|
||||
if (catchUps.get(agentId)?.status === "error") startCatchUp(agentId);
|
||||
}
|
||||
};
|
||||
|
||||
const publishEffectiveMembership = () => {
|
||||
const nextDesired = effectiveAgentIds();
|
||||
if (sameAgentIds(nextDesired, desired)) {
|
||||
if (membershipNeedsRetry) void reconcileMembership();
|
||||
retryFailedCatchUps();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const agentId of desired) {
|
||||
if (!nextDesired.includes(agentId)) cancelCatchUp(agentId);
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
desired = nextDesired;
|
||||
membershipGeneration += 1;
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
return {
|
||||
replaceVisibleAgentIds(sourceId, agentIds) {
|
||||
const normalized = normalizeAgentIds(agentIds);
|
||||
if (normalized.length === 0) sources.delete(sourceId);
|
||||
else sources.set(sourceId, normalized);
|
||||
publishEffectiveMembership();
|
||||
},
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
active = nextActive;
|
||||
publishEffectiveMembership();
|
||||
},
|
||||
setConnected(nextConnected) {
|
||||
if (connected === nextConnected) return;
|
||||
connected = nextConnected;
|
||||
if (!connected) {
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
acknowledged = [];
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
return;
|
||||
}
|
||||
membershipGeneration += 1;
|
||||
void reconcileMembership();
|
||||
},
|
||||
recoverGap(agentId, cursor) {
|
||||
if (!isDesired(agentId)) return;
|
||||
startCatchUp(agentId, {
|
||||
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
|
||||
supersede: true,
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
sources.clear();
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = [];
|
||||
acknowledged = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { reconcileAgentDirectory } from "./agent-directory-reconciliation";
|
||||
|
||||
function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload {
|
||||
return {
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsEntry {
|
||||
return {
|
||||
agent: snapshot(id, status),
|
||||
project: {
|
||||
projectKey: "/repo",
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd: "/repo",
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replica(id: string, status: Agent["status"]): Agent {
|
||||
return {
|
||||
...snapshot(id, status),
|
||||
serverId: "server",
|
||||
createdAt: new Date("2026-07-12T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-12T10:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-07-12T10:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
parentAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent directory reconciliation", () => {
|
||||
it("reports snapshot-only and buffered running transitions exactly once", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
previous: new Map([
|
||||
["snapshot", replica("snapshot", "running")],
|
||||
["buffered", replica("buffered", "running")],
|
||||
]),
|
||||
snapshot: [entry("snapshot", "idle"), entry("buffered", "running")],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: snapshot("buffered", "idle"),
|
||||
project: entry("buffered", "idle").project,
|
||||
},
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: snapshot("buffered", "idle"),
|
||||
project: entry("buffered", "idle").project,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]);
|
||||
expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([
|
||||
["snapshot", "idle"],
|
||||
["buffered", "idle"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves ordered upserts and removals received after page one", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
previous: new Map(),
|
||||
snapshot: [entry("updated", "idle"), entry("removed", "idle")],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: { ...snapshot("updated", "idle"), title: "live" },
|
||||
project: entry("updated", "idle").project,
|
||||
},
|
||||
{ kind: "remove", agentId: "removed" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([
|
||||
["updated", "live"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps newer page metadata when a stale buffered upsert arrives", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
previous: new Map([["agent", replica("agent", "running")]]),
|
||||
snapshot: [
|
||||
{
|
||||
...entry("agent", "running"),
|
||||
agent: {
|
||||
...snapshot("agent", "running"),
|
||||
title: "newer page",
|
||||
updatedAt: "2026-07-12T12:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
...snapshot("agent", "idle"),
|
||||
title: "stale live",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
},
|
||||
project: entry("agent", "idle").project,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect({
|
||||
title: result.entries[0]?.agent.title,
|
||||
status: result.entries[0]?.agent.status,
|
||||
stopped: result.stoppedRunningAgentIds,
|
||||
}).toEqual({ title: "newer page", status: "running", stopped: [] });
|
||||
});
|
||||
|
||||
it("accepts usage from a stale buffered upsert without regressing metadata", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
previous: new Map(),
|
||||
snapshot: [
|
||||
{
|
||||
...entry("agent", "idle"),
|
||||
agent: {
|
||||
...snapshot("agent", "idle"),
|
||||
title: "newer page",
|
||||
updatedAt: "2026-07-12T12:00:00.000Z",
|
||||
lastUsage: { inputTokens: 10, outputTokens: 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
...snapshot("agent", "running"),
|
||||
title: "stale live",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
lastUsage: { inputTokens: 20, outputTokens: 8 },
|
||||
},
|
||||
project: entry("agent", "idle").project,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect({
|
||||
title: result.entries[0]?.agent.title,
|
||||
status: result.entries[0]?.agent.status,
|
||||
usage: result.entries[0]?.agent.lastUsage,
|
||||
}).toEqual({
|
||||
title: "newer page",
|
||||
status: "idle",
|
||||
usage: { inputTokens: 20, outputTokens: 8 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { AgentDirectoryDelta } from "./agent-directory-sync";
|
||||
import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy";
|
||||
|
||||
export function reconcileAgentDirectory(input: {
|
||||
previous: ReadonlyMap<string, Agent>;
|
||||
snapshot: FetchAgentsEntry[];
|
||||
deltas: readonly AgentDirectoryDelta[];
|
||||
}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } {
|
||||
const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry]));
|
||||
const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status]));
|
||||
const stoppedRunningAgentIds = new Set<string>();
|
||||
|
||||
for (const entry of input.snapshot) {
|
||||
if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") {
|
||||
stoppedRunningAgentIds.add(entry.agent.id);
|
||||
}
|
||||
statuses.set(entry.agent.id, entry.agent.status);
|
||||
}
|
||||
|
||||
for (const delta of input.deltas) {
|
||||
if (delta.kind === "remove") {
|
||||
entries.delete(delta.agentId);
|
||||
statuses.delete(delta.agentId);
|
||||
stoppedRunningAgentIds.delete(delta.agentId);
|
||||
continue;
|
||||
}
|
||||
const previousEntry = entries.get(delta.agent.id);
|
||||
const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent);
|
||||
if (statuses.get(delta.agent.id) === "running" && acceptedAgent.status !== "running") {
|
||||
stoppedRunningAgentIds.add(delta.agent.id);
|
||||
}
|
||||
statuses.set(delta.agent.id, acceptedAgent.status);
|
||||
const previousProject = previousEntry?.project;
|
||||
entries.set(delta.agent.id, {
|
||||
agent: acceptedAgent,
|
||||
project: delta.project ??
|
||||
previousProject ?? {
|
||||
projectKey: delta.agent.cwd,
|
||||
projectName: /[^/]+$/.exec(delta.agent.cwd)?.[0] ?? delta.agent.cwd,
|
||||
checkout: {
|
||||
cwd: delta.agent.cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
entries: Array.from(entries.values()),
|
||||
stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds),
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { replaceFetchedAgentDirectory } from "./agent-directory-sync";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
|
||||
|
||||
function createAgentPayload(
|
||||
input: Partial<Omit<AgentSnapshotPayload, "labels">> & {
|
||||
@@ -56,6 +60,10 @@ function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function permission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
|
||||
describe("replaceFetchedAgentDirectory", () => {
|
||||
it("re-derives parentAgentId every time an agent snapshot is ingested", () => {
|
||||
const serverId = "server-1";
|
||||
@@ -92,4 +100,139 @@ describe("replaceFetchedAgentDirectory", () => {
|
||||
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("removes every replica-owned artifact for a removed agent", () => {
|
||||
const serverId = "server-removal";
|
||||
const agentId = "removed-agent";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const agent = {
|
||||
...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId),
|
||||
projectPlacement: null,
|
||||
};
|
||||
store.setAgents(serverId, new Map([[agentId, agent]]));
|
||||
store.setAgentDetails(serverId, new Map([[agentId, agent]]));
|
||||
store.setQueuedMessages(
|
||||
serverId,
|
||||
new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]),
|
||||
);
|
||||
store.setAgentTimelineCursor(
|
||||
serverId,
|
||||
new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]),
|
||||
);
|
||||
store.setPendingPermissions(
|
||||
serverId,
|
||||
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
|
||||
);
|
||||
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
|
||||
|
||||
applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } });
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect({
|
||||
agents: session?.agents.has(agentId),
|
||||
details: session?.agentDetails.has(agentId),
|
||||
queued: session?.queuedMessages.has(agentId),
|
||||
cursor: session?.agentTimelineCursor.has(agentId),
|
||||
permissions: session?.pendingPermissions.size,
|
||||
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
|
||||
}).toEqual({
|
||||
agents: false,
|
||||
details: false,
|
||||
queued: false,
|
||||
cursor: false,
|
||||
permissions: 0,
|
||||
archivePending: false,
|
||||
});
|
||||
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => {
|
||||
const serverId = "server-usage";
|
||||
const agentId = "usage-agent";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
store.setWorkspaces(
|
||||
serverId,
|
||||
new Map([
|
||||
[
|
||||
"legacy-workspace",
|
||||
{
|
||||
id: "legacy-workspace",
|
||||
projectId: "project",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
const current = createAgentPayload({
|
||||
id: agentId,
|
||||
title: "current",
|
||||
status: "running",
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
lastUsage: { inputTokens: 10, outputTokens: 5 },
|
||||
pendingPermissions: [permission("current-permission")],
|
||||
});
|
||||
applyAgentDirectoryDelta({
|
||||
serverId,
|
||||
delta: { kind: "upsert", agent: current, project: createEntry(current).project },
|
||||
});
|
||||
store.flushAgentLastActivity();
|
||||
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
|
||||
|
||||
const staleResult = applyAgentDirectoryDelta({
|
||||
serverId,
|
||||
delta: {
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
...current,
|
||||
title: "stale",
|
||||
status: "idle",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
lastUsage: { inputTokens: 20, outputTokens: 8 },
|
||||
pendingPermissions: [permission("stale-permission")],
|
||||
archivedAt: "2026-07-12T10:00:00.000Z",
|
||||
},
|
||||
project: createEntry(current).project,
|
||||
},
|
||||
});
|
||||
store.flushAgentLastActivity();
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
const agent = state.sessions[serverId]?.agents.get(agentId);
|
||||
expect({
|
||||
title: agent?.title,
|
||||
status: agent?.status,
|
||||
usage: agent?.lastUsage,
|
||||
workspaceId: agent?.workspaceId,
|
||||
stoppedRunning: staleResult.stoppedRunning,
|
||||
permissions: Array.from(state.sessions[serverId]?.pendingPermissions.values() ?? []).map(
|
||||
({ request }) => request.id,
|
||||
),
|
||||
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
|
||||
activity: state.agentLastActivity.get(agentId)?.toISOString(),
|
||||
}).toEqual({
|
||||
title: "current",
|
||||
status: "running",
|
||||
usage: { inputTokens: 20, outputTokens: 8 },
|
||||
workspaceId: "legacy-workspace",
|
||||
stoppedRunning: false,
|
||||
permissions: ["current-permission"],
|
||||
archivePending: true,
|
||||
activity: "2026-07-12T11:00:00.000Z",
|
||||
});
|
||||
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,113 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
|
||||
|
||||
type AgentDirectoryFetchEntry = FetchAgentsEntry;
|
||||
export type AgentDirectoryDelta = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_update" }
|
||||
>["payload"];
|
||||
|
||||
export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): {
|
||||
agentId: string;
|
||||
stoppedRunning: boolean;
|
||||
} {
|
||||
if (input.delta.kind === "remove") {
|
||||
removeAgentDirectoryReplica(input.serverId, input.delta.agentId);
|
||||
return { agentId: input.delta.agentId, stoppedRunning: false };
|
||||
}
|
||||
return upsertAgentDirectoryReplica(input.serverId, input.delta);
|
||||
}
|
||||
|
||||
type AgentUpsertDelta = Extract<AgentDirectoryDelta, { kind: "upsert" }>;
|
||||
|
||||
function upsertAgentDirectoryReplica(
|
||||
serverId: string,
|
||||
delta: AgentUpsertDelta,
|
||||
): { agentId: string; stoppedRunning: boolean } {
|
||||
const normalized = normalizeAgentSnapshot(delta.agent, serverId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const previousAgent =
|
||||
session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
|
||||
const legacyWorkspaceId =
|
||||
previousAgent?.workspaceId ??
|
||||
Array.from(session?.workspaces.values() ?? []).find(
|
||||
(workspace) =>
|
||||
session?.serverInfo?.features?.workspaceMultiplicity !== true &&
|
||||
workspace.workspaceDirectory === normalized.cwd,
|
||||
)?.id;
|
||||
const agent: Agent = {
|
||||
...normalized,
|
||||
workspaceId: normalized.workspaceId ?? legacyWorkspaceId,
|
||||
projectPlacement:
|
||||
resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ??
|
||||
previousAgent?.projectPlacement,
|
||||
};
|
||||
const acceptedAgent = upsertAgentReplica(serverId, agent);
|
||||
if (acceptedAgent.archivedAt) {
|
||||
clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id });
|
||||
}
|
||||
replaceAgentPendingPermissions(serverId, acceptedAgent);
|
||||
useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt);
|
||||
return {
|
||||
agentId: acceptedAgent.id,
|
||||
stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running",
|
||||
};
|
||||
}
|
||||
|
||||
function upsertAgentReplica(serverId: string, agent: Agent): Agent {
|
||||
let acceptedAgent = agent;
|
||||
useSessionStore.getState().setAgents(serverId, (current) => {
|
||||
const currentAgent = current.get(agent.id);
|
||||
acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent);
|
||||
if (acceptedAgent === currentAgent) return current;
|
||||
const next = new Map(current);
|
||||
next.set(agent.id, acceptedAgent);
|
||||
return next;
|
||||
});
|
||||
return acceptedAgent;
|
||||
}
|
||||
|
||||
function replaceAgentPendingPermissions(serverId: string, agent: Agent): void {
|
||||
const pendingPermissions = new Map(
|
||||
useSessionStore.getState().sessions[serverId]?.pendingPermissions,
|
||||
);
|
||||
for (const [key, pending] of pendingPermissions) {
|
||||
if (pending.agentId === agent.id) pendingPermissions.delete(key);
|
||||
}
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
pendingPermissions.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions);
|
||||
}
|
||||
|
||||
function removeAgentDirectoryReplica(serverId: string, agentId: string): void {
|
||||
const store = useSessionStore.getState();
|
||||
clearArchiveAgentPending({ queryClient, serverId, agentId });
|
||||
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
|
||||
if (!current.has(agentId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
};
|
||||
store.setAgents(serverId, removeKey);
|
||||
store.setAgentDetails(serverId, removeKey);
|
||||
store.setQueuedMessages(serverId, removeKey);
|
||||
store.setAgentTimelineCursor(serverId, removeKey);
|
||||
store.setPendingPermissions(serverId, (current) => {
|
||||
const next = new Map(current);
|
||||
for (const [key, pending] of next) {
|
||||
if (pending.agentId === agentId) next.delete(key);
|
||||
}
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
|
||||
}
|
||||
|
||||
interface PendingPermissionEntry {
|
||||
key: string;
|
||||
@@ -49,6 +154,12 @@ export function replaceFetchedAgentDirectory(input: {
|
||||
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
for (const agent of fetchedAgents.values()) {
|
||||
if (agent.archivedAt) {
|
||||
clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id });
|
||||
}
|
||||
}
|
||||
|
||||
store.setAgents(input.serverId, fetchedAgents);
|
||||
store.setAgentDetails(input.serverId, (prev) => {
|
||||
let next: Map<string, Agent> | null = null;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { AgentUsage } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
interface AgentUpdateValue {
|
||||
updatedAt: Date | string;
|
||||
lastUsage?: AgentUsage;
|
||||
}
|
||||
|
||||
function timestamp(value: Date | string): number {
|
||||
return value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
}
|
||||
|
||||
export function acceptAgentDirectoryUpdate<T extends AgentUpdateValue>(
|
||||
current: T | undefined,
|
||||
incoming: T,
|
||||
): T {
|
||||
if (!current || timestamp(incoming.updatedAt) >= timestamp(current.updatedAt)) return incoming;
|
||||
if (equal(incoming.lastUsage, current.lastUsage)) return current;
|
||||
return { ...current, lastUsage: incoming.lastUsage };
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
|
||||
return true;
|
||||
}
|
||||
|
||||
async function readLegacyDaemonWorkspaceDirectory(input: {
|
||||
export async function readLegacyDaemonWorkspaceDirectory(input: {
|
||||
client: Pick<DaemonClient, "fetchAgents">;
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
page?: FetchAgentsOptions["page"];
|
||||
@@ -193,7 +193,7 @@ export function applyLegacyDaemonWorkspaceOwnership(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function replaceLegacyDaemonWorkspaceDirectory(input: {
|
||||
export function replaceLegacyDaemonWorkspaceDirectory(input: {
|
||||
serverId: string;
|
||||
entries: FetchAgentsEntry[];
|
||||
}): LegacyDaemonWorkspaceSnapshot {
|
||||
@@ -242,7 +242,7 @@ function readFetchAgentsNextCursor(
|
||||
return null;
|
||||
}
|
||||
|
||||
function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] {
|
||||
export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const workspaceId = resolveLegacyWorkspaceId(entry);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user