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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user