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:
@@ -1849,6 +1849,185 @@ test("fetches paginated agent history separately from active agents", async () =
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches scoped recent provider sessions", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.fetchRecentProviderSessions({
|
||||
cwd: "/tmp/repo",
|
||||
providers: ["my-claude"],
|
||||
since: "2026-04-30T00:00:00.000Z",
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "fetch_recent_provider_sessions_request";
|
||||
requestId: string;
|
||||
cwd?: string;
|
||||
providers?: string[];
|
||||
since?: string;
|
||||
limit?: number;
|
||||
};
|
||||
};
|
||||
expect(request.message).toMatchObject({
|
||||
type: "fetch_recent_provider_sessions_request",
|
||||
cwd: "/tmp/repo",
|
||||
providers: ["my-claude"],
|
||||
since: "2026-04-30T00:00:00.000Z",
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fetch_recent_provider_sessions_response",
|
||||
payload: {
|
||||
requestId: request.message.requestId,
|
||||
entries: [
|
||||
{
|
||||
providerId: "codex",
|
||||
providerLabel: "Codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
title: "Import me",
|
||||
firstPromptPreview: "first prompt",
|
||||
lastPromptPreview: "last prompt",
|
||||
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.message.requestId,
|
||||
entries: [
|
||||
{
|
||||
providerId: "codex",
|
||||
providerLabel: "Codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
title: "Import me",
|
||||
firstPromptPreview: "first prompt",
|
||||
lastPromptPreview: "last prompt",
|
||||
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("imports an agent by provider handle id", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.importAgent({
|
||||
providerId: "custom-codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(String(mock.sent[0])) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "import_agent_request";
|
||||
requestId: string;
|
||||
providerId?: string;
|
||||
providerHandleId?: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
};
|
||||
expect(request.message).toMatchObject({
|
||||
type: "import_agent_request",
|
||||
providerId: "custom-codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
expect(request.message).not.toHaveProperty("sessionId");
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_resumed",
|
||||
requestId: request.message.requestId,
|
||||
agentId: "agent-1",
|
||||
timelineSize: 0,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
provider: "custom-codex",
|
||||
cwd: "/tmp/repo",
|
||||
model: null,
|
||||
features: [],
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: {
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-1",
|
||||
nativeHandle: "thread-1",
|
||||
},
|
||||
title: null,
|
||||
labels: {},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toMatchObject({
|
||||
id: "agent-1",
|
||||
provider: "custom-codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses server-provided dictation finish timeout budget", async () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -123,13 +123,21 @@ const perfNow: () => number =
|
||||
? () => performance.now()
|
||||
: () => Date.now();
|
||||
|
||||
export interface ImportAgentInput {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
interface ImportAgentInputBase {
|
||||
cwd?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type ImportAgentInput =
|
||||
| (ImportAgentInputBase & {
|
||||
providerId: string;
|
||||
providerHandleId: string;
|
||||
})
|
||||
| (ImportAgentInputBase & {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
});
|
||||
|
||||
function normalizePassword(value: string | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -438,6 +446,21 @@ export type FetchAgentHistoryOptions = Omit<FetchAgentHistoryRequest, "type" | "
|
||||
};
|
||||
export type FetchAgentHistoryEntry = FetchAgentHistoryPayload["entries"][number];
|
||||
export type FetchAgentHistoryPageInfo = FetchAgentHistoryPayload["pageInfo"];
|
||||
type FetchRecentProviderSessionsPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_recent_provider_sessions_response" }
|
||||
>["payload"];
|
||||
type FetchRecentProviderSessionsRequest = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "fetch_recent_provider_sessions_request" }
|
||||
>;
|
||||
export type FetchRecentProviderSessionsOptions = Omit<
|
||||
FetchRecentProviderSessionsRequest,
|
||||
"type" | "requestId"
|
||||
> & {
|
||||
requestId?: string;
|
||||
};
|
||||
export type FetchRecentProviderSessionEntry = FetchRecentProviderSessionsPayload["entries"][number];
|
||||
type FetchWorkspacesPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_workspaces_response" }
|
||||
@@ -1553,6 +1576,35 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchRecentProviderSessions(
|
||||
options?: FetchRecentProviderSessionsOptions,
|
||||
): Promise<FetchRecentProviderSessionsPayload> {
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_recent_provider_sessions_request",
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
...(options?.providers ? { providers: options.providers } : {}),
|
||||
...(options?.since ? { since: options.since } : {}),
|
||||
...(options?.limit ? { limit: options.limit } : {}),
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "fetch_recent_provider_sessions_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload> {
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -1902,8 +1954,9 @@ export class DaemonClient {
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "import_agent_request",
|
||||
requestId,
|
||||
provider: input.provider,
|
||||
sessionId: input.sessionId,
|
||||
...("providerId" in input
|
||||
? { providerId: input.providerId, providerHandleId: input.providerHandleId }
|
||||
: { provider: input.provider, sessionId: input.sessionId }),
|
||||
...(input.cwd ? { cwd: input.cwd } : {}),
|
||||
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user