mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Fix archived agent navigation and recovery (#4033)
* fix(app): preserve archived agent navigation Make explicit archived-agent opens one atomic layout command and defer them until persisted layout hydration. Use the provider's numeric active-session sentinel so recovery can complete. * fix(app): preserve timeline resume during hydration Paint persisted canonical rows with the current live head and retain their resume cursor. This prevents tail replay without dropping stream updates that arrive while replica storage loads. * fix(app): serialize replica reads with cache writes
This commit is contained in:
@@ -23,7 +23,7 @@ async function completeSubmittedTurn(
|
||||
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ async function expectTurnCompletesNormally(
|
||||
): Promise<void> {
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
@@ -144,6 +146,8 @@ test.describe("Archive tab reconciliation", () => {
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
|
||||
@@ -508,6 +508,21 @@ describe("ReplicaCache", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries a timeline read invalidated by a concurrent directory commit", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline"));
|
||||
await cache.flush();
|
||||
storage.onRead = () => {
|
||||
storage.onRead = null;
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
};
|
||||
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
|
||||
timelineItem("Persisted timeline"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rebuilds every directory row before restoring its checkpoint after eviction", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 });
|
||||
|
||||
@@ -898,13 +898,15 @@ export class ReplicaCache {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
if (!this.activeServerIds.has(serverId)) return [];
|
||||
const revision = this.hostRevisions.get(serverId) ?? 0;
|
||||
try {
|
||||
await this.prepareStore();
|
||||
await this.flush();
|
||||
if (!this.canReadHostRevision(serverId, revision)) return [];
|
||||
const rows = await this.rowStore.read(serverId, kinds, ids);
|
||||
return this.canReadHostRevision(serverId, revision) ? rows : [];
|
||||
while (this.activeServerIds.has(serverId)) {
|
||||
await this.flush();
|
||||
const revision = this.hostRevisions.get(serverId) ?? 0;
|
||||
const rows = await this.rowStore.read(serverId, kinds, ids);
|
||||
if (this.canReadHostRevision(serverId, revision)) return rows;
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
||||
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
||||
getSessionAgents: (serverId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
|
||||
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
|
||||
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
|
||||
pinAgent: (workspaceKey, agentId) =>
|
||||
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
|
||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||
navigateToRoute: (route) => {
|
||||
navigateToHostWorkspaceRoute(route);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
interface RecordedTab {
|
||||
workspaceKey: string;
|
||||
target: WorkspaceTabTarget;
|
||||
pin: boolean;
|
||||
}
|
||||
|
||||
function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
@@ -23,11 +24,11 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
const deps: NavigateToWorkspaceDeps = {
|
||||
getSessionWorkspaces: () => null,
|
||||
getSessionAgents: () => [] as Agent[],
|
||||
openTab: ({ workspaceKey, target }) => {
|
||||
openedTabs.push({ workspaceKey, target });
|
||||
isWorkspaceLayoutHydrated: () => true,
|
||||
openTab: ({ workspaceKey, target, pin = false }) => {
|
||||
openedTabs.push({ workspaceKey, target, pin });
|
||||
return target.kind === "agent" ? target.agentId : null;
|
||||
},
|
||||
pinAgent: () => undefined,
|
||||
rememberLastWorkspace: (selection) => remembered.push(selection),
|
||||
navigateToRoute: (route) => navigations.push(route),
|
||||
...overrides,
|
||||
@@ -97,6 +98,7 @@ describe("workspace navigation", () => {
|
||||
{
|
||||
workspaceKey: "server-1:workspace-a",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
pin: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -131,6 +133,7 @@ describe("workspace navigation", () => {
|
||||
{
|
||||
workspaceKey: "server-1:workspace-a",
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
pin: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -153,6 +156,30 @@ describe("workspace navigation", () => {
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
});
|
||||
|
||||
it("defers an agent tab until persisted workspace layout has hydrated", () => {
|
||||
const workspace = {
|
||||
id: "workspace-a",
|
||||
workspaceDirectory: "/repo/workspace-a",
|
||||
} as WorkspaceDescriptor;
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
|
||||
isWorkspaceLayoutHydrated: () => false,
|
||||
});
|
||||
|
||||
navigateToWorkspace(
|
||||
{
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
pin: true,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
const selection = parseActiveWorkspaceSelection({
|
||||
pathname: "/h/server-1/workspace/workspace-a",
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface NavigateToWorkspaceInput {
|
||||
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
||||
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
|
||||
getSessionAgents: (serverId: string) => Iterable<Agent>;
|
||||
isWorkspaceLayoutHydrated: () => boolean;
|
||||
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
|
||||
navigateToRoute: (route: string) => void;
|
||||
}
|
||||
@@ -90,8 +91,11 @@ export function navigateToWorkspace(
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const shouldDeferAgentOpen = Boolean(
|
||||
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
|
||||
);
|
||||
if (input.target) {
|
||||
if (resolvedWorkspaceId || input.target.kind !== "agent") {
|
||||
if (!shouldDeferAgentOpen) {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
}
|
||||
} else {
|
||||
@@ -111,7 +115,7 @@ export function navigateToWorkspace(
|
||||
}
|
||||
|
||||
const route =
|
||||
input.target?.kind === "agent" && !resolvedWorkspaceId
|
||||
input.target?.kind === "agent" && shouldDeferAgentOpen
|
||||
? buildHostWorkspaceOpenRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
|
||||
@@ -3092,7 +3092,7 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps pinned archived agents in memory per workspace without persisting them", () => {
|
||||
it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
@@ -3102,9 +3102,18 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(otherWorkspaceKey as string, "agent-2");
|
||||
store.openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
store.openTab({
|
||||
workspaceKey: otherWorkspaceKey as string,
|
||||
target: { kind: "agent", agentId: "agent-2" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
@@ -3122,13 +3131,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
explorerSidebarWidthByWorkspace: {},
|
||||
explorerPaneIdByWorkspace: {},
|
||||
sidePaneIdByWorkspace: {},
|
||||
});
|
||||
expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace");
|
||||
});
|
||||
|
||||
it("keeps hidden agent intents in memory per workspace without persisting them", () => {
|
||||
@@ -3653,14 +3656,19 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("pinning an agent clears hidden intent", () => {
|
||||
it("opening a pinned agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
|
||||
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
@@ -3675,8 +3683,8 @@ describe("workspace-layout-store actions", () => {
|
||||
workspaceKey: workspaceKey,
|
||||
target: { kind: "agent", agentId: "archived-agent" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
store.pinAgent(workspaceKey, "archived-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
@@ -3730,8 +3738,8 @@ describe("workspace-layout-store actions", () => {
|
||||
workspaceKey: workspaceKey,
|
||||
target: { kind: "agent", agentId: "archived-agent" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
store.pinAgent(workspaceKey, "archived-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
@@ -3749,6 +3757,48 @@ describe("workspace-layout-store actions", () => {
|
||||
).toEqual(["agent_archived-agent"]);
|
||||
});
|
||||
|
||||
it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "first-agent" },
|
||||
intent: "reveal",
|
||||
});
|
||||
store.openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "second-agent" },
|
||||
intent: "reveal",
|
||||
});
|
||||
store.hideAgent(workspaceKey, "archived-agent");
|
||||
|
||||
const archivedTabId = store.openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "archived-agent" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["first-agent", "second-agent"],
|
||||
autoOpenAgentIds: ["first-agent", "second-agent"],
|
||||
knownAgentIds: ["first-agent", "second-agent"],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
|
||||
const state = workspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[workspaceKey];
|
||||
expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toContain(
|
||||
archivedTabId,
|
||||
);
|
||||
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(archivedTabId);
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toContain(
|
||||
"archived-agent",
|
||||
);
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retargeting a tab to an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface OpenWorkspaceTabInput {
|
||||
workspaceKey: string;
|
||||
target: WorkspaceTabTarget;
|
||||
intent: WorkspaceTabOpenIntent;
|
||||
/** Keeps an explicitly opened agent visible even when it is archived. */
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
parentTabId?: string;
|
||||
state?: JsonValue;
|
||||
@@ -159,7 +161,6 @@ interface WorkspaceLayoutStore {
|
||||
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
|
||||
resizeExplorerSidebar: (workspaceKey: string, width: number) => void;
|
||||
reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void;
|
||||
pinAgent: (workspaceKey: string, agentId: string) => void;
|
||||
unpinAgent: (workspaceKey: string, agentId: string) => void;
|
||||
hideAgent: (workspaceKey: string, agentId: string) => void;
|
||||
unhideAgent: (workspaceKey: string, agentId: string) => void;
|
||||
@@ -811,6 +812,7 @@ export function createWorkspaceLayoutStore(
|
||||
placement.explorerSidebarPaneId,
|
||||
placement.layout.focusedPaneId,
|
||||
);
|
||||
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
@@ -821,6 +823,20 @@ export function createWorkspaceLayoutStore(
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget.agentId,
|
||||
),
|
||||
pinnedAgentIdsByWorkspace: shouldPinAgent
|
||||
? addAgentIdToWorkspaceSet(
|
||||
state.pinnedAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget.agentId,
|
||||
)
|
||||
: state.pinnedAgentIdsByWorkspace,
|
||||
pendingAgentIdsByWorkspace: shouldPinAgent
|
||||
? addAgentIdToWorkspaceSet(
|
||||
state.pendingAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget.agentId,
|
||||
)
|
||||
: state.pendingAgentIdsByWorkspace,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: input.parentTabId
|
||||
@@ -1639,46 +1655,6 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
pinAgent: (workspaceKey, agentId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const currentPinnedAgentIds =
|
||||
state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
|
||||
const currentPendingAgentIds =
|
||||
state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
|
||||
if (
|
||||
currentPinnedAgentIds?.has(normalizedAgentId) &&
|
||||
currentPendingAgentIds?.has(normalizedAgentId)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextPinnedAgentIds = new Set(currentPinnedAgentIds ?? []);
|
||||
nextPinnedAgentIds.add(normalizedAgentId);
|
||||
|
||||
return {
|
||||
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedAgentId,
|
||||
),
|
||||
pinnedAgentIdsByWorkspace: {
|
||||
...state.pinnedAgentIdsByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextPinnedAgentIds,
|
||||
},
|
||||
pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet(
|
||||
state.pendingAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedAgentId,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
unpinAgent: (workspaceKey, agentId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
|
||||
@@ -138,6 +138,103 @@ describe("viewed timeline persistence", () => {
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("paints cached rows without replacing a live head that arrives during preparation", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
const read = new Promise<CachedTimeline>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: {
|
||||
readTimeline: () => read,
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
|
||||
const preparation = replica.prepare(AGENT_ID);
|
||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, {
|
||||
head: [item("live", "live", 5)],
|
||||
});
|
||||
release(cachedTimeline());
|
||||
await preparation;
|
||||
|
||||
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
|
||||
).toEqual({ status: "painted", items: cachedTimeline().items });
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([
|
||||
item("live", "live", 5),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
const read = new Promise<CachedTimeline>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: {
|
||||
readTimeline: () => read,
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
|
||||
const preparation = replica.prepare(AGENT_ID);
|
||||
useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, {
|
||||
head: [item("cached", "cached", 4), item("live", "live", 5)],
|
||||
});
|
||||
release(cachedTimeline());
|
||||
await preparation;
|
||||
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect([
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]);
|
||||
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
|
||||
});
|
||||
|
||||
it("reconciles cached rows with a non-authoritative timeline painted during preparation", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
const read = new Promise<CachedTimeline>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: {
|
||||
readTimeline: () => read,
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
|
||||
const preparation = replica.prepare(AGENT_ID);
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, {
|
||||
items: [item("live", "live", 5)],
|
||||
head: [],
|
||||
range: null,
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
release(cachedTimeline());
|
||||
await preparation;
|
||||
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect([
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]);
|
||||
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
|
||||
});
|
||||
|
||||
it("persists accepted live stream commits through the owner", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
applySynced(AGENT_ID, 8);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./session-stream-reducers";
|
||||
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
|
||||
import { createInstalledTimelineTransform, type TimelineItemTransform } from "@/plugins/timeline";
|
||||
import { replaceWithCanonicalStream } from "@/types/stream";
|
||||
|
||||
const PLUGIN_TIMELINE_REPROJECTION_DELAY_MS = 50;
|
||||
|
||||
@@ -50,21 +51,41 @@ async function prepareCachedTimeline(input: {
|
||||
if (!stored) return undefined;
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const currentTimeline = selectAgentTimelineState(session, input.agentId);
|
||||
if (session?.agentStreamHead.get(input.agentId) !== beforeHead) return undefined;
|
||||
if (beforeTimeline.status === "painted") {
|
||||
return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items
|
||||
? stored
|
||||
: undefined;
|
||||
const currentHead = session?.agentStreamHead.get(input.agentId);
|
||||
if (currentTimeline.status === "synced") return undefined;
|
||||
if (!stored.range) {
|
||||
if (currentHead !== beforeHead) return undefined;
|
||||
if (beforeTimeline.status === "painted") {
|
||||
return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items
|
||||
? stored
|
||||
: undefined;
|
||||
}
|
||||
if (currentTimeline.status !== "cold") return undefined;
|
||||
}
|
||||
if (currentTimeline.status !== "cold") return undefined;
|
||||
const liveItems =
|
||||
currentTimeline.status === "painted"
|
||||
? [...currentTimeline.items, ...(currentHead ?? [])]
|
||||
: (currentHead ?? []);
|
||||
const replacement = stored.range
|
||||
? replaceWithCanonicalStream({
|
||||
canonical: stored.items,
|
||||
previousTail: [],
|
||||
previousHead: liveItems,
|
||||
sendingClientMessageIds: getSendingClientMessageIds(
|
||||
session?.messageSubmissions.get(input.agentId),
|
||||
),
|
||||
preserveContinuity: true,
|
||||
canonicalCoverage: stored.range,
|
||||
})
|
||||
: { tail: stored.items, head: liveItems, acknowledgedClientMessageIds: [] };
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
|
||||
items: stored.items,
|
||||
head: [],
|
||||
items: replacement.tail,
|
||||
head: replacement.head,
|
||||
range: stored.range,
|
||||
older: stored.hasOlder ? "available" : "none",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
acknowledgedClientMessageIds: [],
|
||||
acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds,
|
||||
});
|
||||
return stored;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export interface PrepareWorkspaceTabDeps {
|
||||
workspaceKey: string;
|
||||
target: WorkspaceTabTarget;
|
||||
intent: "reveal";
|
||||
pin?: boolean;
|
||||
placement?: WorkspaceTabPlacement;
|
||||
}) => string | null;
|
||||
pinAgent: (workspaceKey: string, agentId: string) => void;
|
||||
}
|
||||
|
||||
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
|
||||
@@ -38,9 +38,11 @@ export function prepareWorkspaceTab(
|
||||
workspaceId: input.workspaceId,
|
||||
}) ?? "";
|
||||
|
||||
deps.openTab({ workspaceKey: key, target, intent: "reveal", placement: input.placement });
|
||||
|
||||
if (input.pin && target.kind === "agent") {
|
||||
deps.pinAgent(key, target.agentId);
|
||||
}
|
||||
deps.openTab({
|
||||
workspaceKey: key,
|
||||
target,
|
||||
intent: "reveal",
|
||||
pin: input.pin === true && target.kind === "agent",
|
||||
placement: input.placement,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,32 +9,25 @@ const AGENT_ID = "agent-1";
|
||||
interface RecordedOpenedTab {
|
||||
key: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
interface RecordedPin {
|
||||
key: string;
|
||||
agentId: string;
|
||||
pin: boolean;
|
||||
}
|
||||
|
||||
function createFakeLayout() {
|
||||
const openedTabs: RecordedOpenedTab[] = [];
|
||||
const pinnedAgents: RecordedPin[] = [];
|
||||
return {
|
||||
openedTabs,
|
||||
pinnedAgents,
|
||||
openTab: ({
|
||||
workspaceKey: key,
|
||||
target,
|
||||
pin = false,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
target: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
}) => {
|
||||
openedTabs.push({ key, target });
|
||||
openedTabs.push({ key, target, pin });
|
||||
return target.kind === "agent" ? target.agentId : null;
|
||||
},
|
||||
pinAgent: (key: string, agentId: string) => {
|
||||
pinnedAgents.push({ key, agentId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,8 +45,33 @@ describe("prepareWorkspaceTab", () => {
|
||||
);
|
||||
|
||||
expect(layout.openedTabs).toEqual([
|
||||
{ key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } },
|
||||
{
|
||||
key: "server-1:/repo/worktree",
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
pin: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests pinned visibility in the same command that reveals an agent", () => {
|
||||
const layout = createFakeLayout();
|
||||
|
||||
prepareWorkspaceTab(
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
pin: true,
|
||||
},
|
||||
layout,
|
||||
);
|
||||
|
||||
expect(layout.openedTabs).toEqual([
|
||||
{
|
||||
key: "server-1:/repo/worktree",
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
pin: true,
|
||||
},
|
||||
]);
|
||||
expect(layout.pinnedAgents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,9 @@ function layoutStoreDeps() {
|
||||
workspaceKey: string;
|
||||
target: WorkspaceTabTarget;
|
||||
intent: "reveal";
|
||||
pin?: boolean;
|
||||
placement?: import("@/stores/workspace-layout-actions").WorkspaceTabPlacement;
|
||||
}) => store.openTab(input),
|
||||
pinAgent: store.pinAgent,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -460,7 +460,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
{
|
||||
sessionID: "session-1",
|
||||
directory: cwd,
|
||||
time: { archived: null },
|
||||
time: { archived: 0 },
|
||||
},
|
||||
]);
|
||||
expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true);
|
||||
|
||||
@@ -1636,12 +1636,13 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
||||
await this.setNativeSessionArchived(handle, null);
|
||||
// OpenCode's numeric archive field uses zero as the active-session sentinel.
|
||||
await this.setNativeSessionArchived(handle, 0);
|
||||
}
|
||||
|
||||
private async setNativeSessionArchived(
|
||||
handle: AgentPersistenceHandle,
|
||||
archivedAt: number | null,
|
||||
archivedAt: number,
|
||||
): Promise<void> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
if (!metadata.cwd) {
|
||||
@@ -1657,15 +1658,8 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
directory: metadata.cwd,
|
||||
});
|
||||
try {
|
||||
// OpenCode accepts null to clear the archive timestamp, but this SDK
|
||||
// release's generated request type still exposes only number.
|
||||
const updateSession = client.session.update.bind(client.session) as (parameters: {
|
||||
sessionID: string;
|
||||
directory?: string;
|
||||
time?: { archived?: number | null };
|
||||
}) => ReturnType<typeof client.session.update>;
|
||||
const response = readOpenCodeRecord(
|
||||
await updateSession({
|
||||
await client.session.update({
|
||||
sessionID: handle.sessionId,
|
||||
directory: metadata.cwd,
|
||||
time: { archived: archivedAt },
|
||||
@@ -1673,7 +1667,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
);
|
||||
if (response?.error) {
|
||||
throw new Error(
|
||||
`Failed to ${archivedAt === null ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
`Failed to ${archivedAt === 0 ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user