Import existing Claude/Codex/OpenCode sessions (#766)

* Restrict provider import fan-out to enabled, non-derived allowlist

Move provider eligibility from a session-level post-filter to the agent
manager's fan-out so disabled providers are never spawned (fixes the
Gemini-disabled hang) and derived providers (like a custom Z.AI extending
Claude) don't duplicate their base provider's sessions.

Add an IMPORTABLE_PROVIDERS allowlist (claude, codex, opencode); ACP-based
providers are excluded from import discovery entirely.

* Filter Codex persisted threads by cwd before hydrating turns

Codex's listPersistedAgents previously called thread/read includeTurns on
every thread returned by thread/list, even when the import sheet was
scoped to one cwd. With many recent threads outside the scope, that fan
of reads dominated the import-sheet open latency.

Add an optional cwd hint to ListPersistedAgentsOptions, thread it through
the agent manager, and have Codex pre-filter the cheap thread/list rows by
cwd before scheduling the per-thread hydration. Widen the thread/list
window when filtering since most rows will be from other cwds. Other
providers ignore the hint.

* Fan out import session fetch per enabled provider

Sheet now reads the providers snapshot already warmed by the workspace
screen, filters to enabled importable providers (claude/codex/opencode),
and issues one fetchRecentProviderSessions request per provider via
useQueries with limit 15. Results are merged, deduped by handle, and
sorted by last activity.

Falls back to a single unfiltered request when the daemon does not
expose a provider snapshot (older servers). Per-provider failures show
inline so a single broken provider does not hide the rest.

* Add provider filter badges to import sheet

Show "All" plus one badge per enabled importable provider above the
session list when more than one is available. Single-select narrows the
already-merged list — no refetch. Each row now shows the provider's
icon next to its label so multiple providers stay visually distinct.

Selection resets when the sheet closes.

* Distinguish already-imported empty state in import sheet

Track how many descriptors were dropped because their handle was
already imported, surface the count back to the client as an optional
`filteredAlreadyImportedCount`, and switch the sheet copy to
"All recent sessions are already imported." when the merged list is
empty solely due to that filter.

* Cover already-imported provider session filtering

* List persisted OpenCode sessions for import

* Revalidate import sessions when sheet reopens

* Add agent persistSession plumbing

* Drop redundant visibility-transition refetch and test boilerplate

The import sheet's queries are gated on `enabled: visible`, and react-query
already refetches stale data when `enabled` flips on, so the manual
`wasVisibleRef` + `invalidateQueries` effect was redundant. Collapse the
two `selectedProvider` reset effects into one.

Drop `expect(...).toBeTruthy()` after `findByText` / `getByText` — those
helpers throw on miss, so the assertion was noise.

Drop `| null` from `buildOpenCodePersistedAgentDescriptor`'s return type
(it never returns null) and the dead `Boolean(descriptor)` filter at the
caller.

* Add provider-opaque session import path

Introduces fetchRecentProviderSessions on the daemon client plus an
importAgent overload accepting providerId/providerHandleId, and a
projection helper that renders persisted descriptors as provider-opaque
recent-session payloads with prompt previews.

* Unslop the import-session feature

Centralizes the importable-providers list, normalizes import_agent_request
shape at one boundary, and removes the per-provider fan-out fallback in
the import sheet. Old daemons without features.providersSnapshot now show
"Update host" instead of being served a degraded path.

Replaces internal-helper unit tests on the OpenCode and Codex providers
with boundary tests through listPersistedAgents; makes storageRoot
injectable on the OpenCode provider so the boundary test can target a
temp directory.

Splits the CLAUDE.md back-compat rule into two contracts: protocol stays
backward-compatible (always); features may require new daemons (no
fallback paths). Tags the two new compat shims with COMPAT(name) and
target removal dates.

* Extract importable-session listing into its own module

Move listImportableProviderSessions out of session.ts and drop the
unused generic listPersistedAgents from AgentManager.
This commit is contained in:
Mohamed Boudra
2026-05-09 16:36:06 +08:00
committed by GitHub
parent 8be2692b8a
commit 5b884aee0c
24 changed files with 3373 additions and 45 deletions
@@ -21,6 +21,7 @@ import type {
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
PersistedAgentDescriptor,
} from "./agent/agent-sdk-types.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
@@ -62,6 +63,10 @@ interface SessionTestAccess {
list(...args: unknown[]): Promise<unknown[]>;
get(agentId: string): Promise<unknown>;
};
agentManager: {
listAgents(): unknown[];
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
};
workspaceRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
@@ -228,6 +233,30 @@ function makeManagedAgent(input: {
};
}
function makePersistedProviderSession(input: {
provider: string;
sessionId: string;
nativeHandle?: string;
cwd: string;
title?: string | null;
lastActivityAt: string;
firstPrompt?: string;
}): PersistedAgentDescriptor {
return {
provider: input.provider,
sessionId: input.sessionId,
cwd: input.cwd,
title: input.title ?? null,
lastActivityAt: new Date(input.lastActivityAt),
persistence: {
provider: input.provider,
sessionId: input.sessionId,
...(input.nativeHandle ? { nativeHandle: input.nativeHandle } : {}),
},
timeline: input.firstPrompt ? [{ type: "user_message", text: input.firstPrompt }] : [],
};
}
function agentIdsFromEntries(entries: Array<Record<string, unknown>>) {
return entries.map((entry) => AgentIdEntrySchema.parse(entry).agent.id);
}
@@ -1843,6 +1872,238 @@ test("fetch_agent_history_request pages archived historical rows separately", as
expect(session.agentUpdatesSubscription).toBeNull();
});
test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => [
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
},
},
];
const persistedDescriptors: PersistedAgentDescriptor[] = [
makePersistedProviderSession({
provider: "codex",
sessionId: "outside-filter",
nativeHandle: "outside-filter-handle",
cwd: "/tmp/elsewhere",
title: "Outside filter",
lastActivityAt: "2026-04-30T12:05:00.000Z",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "stored-session",
nativeHandle: "stored-handle",
cwd: "/tmp/recent",
title: "Already stored",
lastActivityAt: "2026-04-30T12:04:00.000Z",
}),
makePersistedProviderSession({
provider: "claude",
sessionId: "wrong-provider",
cwd: "/tmp/recent",
title: "Wrong provider",
lastActivityAt: "2026-04-30T12:03:00.000Z",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "older-session",
nativeHandle: "older-handle",
cwd: "/tmp/recent",
title: "Older than since",
lastActivityAt: "2026-04-29T23:59:59.000Z",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "newer-session",
nativeHandle: "newer-handle",
cwd: "/tmp/recent",
title: "Newer import",
lastActivityAt: "2026-04-30T12:02:00.000Z",
firstPrompt: "newer prompt",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "second-session",
nativeHandle: "second-handle",
cwd: "/tmp/recent",
title: "Second import",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "second prompt",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "third-session",
nativeHandle: "third-handle",
cwd: "/tmp/recent",
title: "Third import",
lastActivityAt: "2026-04-30T11:59:00.000Z",
firstPrompt: "third prompt",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
}),
];
// The real AgentManager filters by providerFilter at the fan-out level
// (Phase 1). Mirror that here so the mock matches the contract.
session.agentManager.listImportablePersistedAgents = async (options?: unknown) => {
const providerFilter = (options as { providerFilter?: Set<string> } | undefined)
?.providerFilter;
if (!providerFilter) {
return persistedDescriptors;
}
return persistedDescriptors.filter((d) => providerFilter.has(d.provider));
};
session.agentStorage.list = async () => [
{
id: "stored-agent",
provider: "codex",
cwd: "/tmp/recent",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
title: "Stored",
labels: {},
lastStatus: "closed",
persistence: {
provider: "codex",
sessionId: "stored-session",
nativeHandle: "stored-handle",
},
},
];
await session.handleMessage({
type: "fetch_recent_provider_sessions_request",
requestId: "req-recent-provider-sessions",
cwd: "/tmp/recent",
providers: ["codex"],
since: "2026-04-30T00:00:00.000Z",
limit: 2,
});
expect(emitted).toEqual([
{
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "newer-handle",
cwd: "/tmp/recent",
title: "Newer import",
firstPromptPreview: "newer prompt",
lastPromptPreview: "newer prompt",
lastActivityAt: "2026-04-30T12:02:00.000Z",
},
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "second-handle",
cwd: "/tmp/recent",
title: "Second import",
firstPromptPreview: "second prompt",
lastPromptPreview: "second prompt",
lastActivityAt: "2026-04-30T12:00:00.000Z",
},
],
filteredAlreadyImportedCount: 2,
},
},
]);
});
test("fetch_recent_provider_sessions_request forwards providerFilter to agent manager", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
let capturedOptions: { providerFilter?: Set<string>; limit?: number } | undefined;
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => [];
session.agentStorage.list = async () => [];
session.agentManager.listImportablePersistedAgents = async (options?: unknown) => {
capturedOptions = options as { providerFilter?: Set<string>; limit?: number };
return [];
};
await session.handleMessage({
type: "fetch_recent_provider_sessions_request",
requestId: "req-provider-filter",
cwd: "/tmp/recent",
providers: ["claude"],
});
expect(capturedOptions?.providerFilter).toBeInstanceOf(Set);
expect(Array.from(capturedOptions?.providerFilter ?? [])).toEqual(["claude"]);
expect(emitted).toEqual([
{
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-provider-filter",
entries: [],
},
},
]);
});
test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCount when all candidates are already imported", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => [
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
},
},
];
session.agentStorage.list = async () => [];
session.agentManager.listImportablePersistedAgents = async () => [
makePersistedProviderSession({
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
}),
];
await session.handleMessage({
type: "fetch_recent_provider_sessions_request",
requestId: "req-all-imported",
cwd: "/tmp/recent",
providers: ["codex"],
});
expect(emitted).toEqual([
{
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-all-imported",
entries: [],
filteredAlreadyImportedCount: 1,
},
},
]);
});
test("fetch_agent_request still resolves archived historical agents", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();