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
@@ -0,0 +1,6 @@
/**
* Providers eligible for "import recent session" discovery. ACP-based
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const;
+45 -2
View File
@@ -661,6 +661,21 @@ export type AgentListItemPayload = z.infer<typeof AgentListItemPayloadSchema>;
export type AgentStreamEventPayload = z.infer<typeof AgentStreamEventPayloadSchema>;
export const RecentProviderSessionDescriptorPayloadSchema = z.object({
providerId: z.string(),
providerLabel: z.string(),
providerHandleId: z.string(),
cwd: z.string(),
title: z.string().nullable(),
firstPromptPreview: z.string().nullable(),
lastPromptPreview: z.string().nullable(),
lastActivityAt: z.string(),
});
export type RecentProviderSessionDescriptorPayload = z.infer<
typeof RecentProviderSessionDescriptorPayloadSchema
>;
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
@@ -905,6 +920,15 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({
.optional(),
});
export const FetchRecentProviderSessionsRequestMessageSchema = z.object({
type: z.literal("fetch_recent_provider_sessions_request"),
requestId: z.string(),
cwd: z.string().optional(),
providers: z.array(z.string()).optional(),
since: z.string().optional(),
limit: z.number().int().positive().max(200).optional(),
});
export const FetchAgentRequestMessageSchema = z.object({
type: z.literal("fetch_agent_request"),
requestId: z.string(),
@@ -1060,8 +1084,10 @@ export const ResumeAgentRequestMessageSchema = z.object({
export const ImportAgentRequestMessageSchema = z.object({
type: z.literal("import_agent_request"),
provider: AgentProviderSchema,
sessionId: z.string(),
provider: AgentProviderSchema.optional(),
providerId: z.string().optional(),
sessionId: z.string().optional(),
providerHandleId: z.string().optional(),
cwd: z.string().optional(),
labels: z.record(z.string()).optional(),
requestId: z.string(),
@@ -1680,6 +1706,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AudioPlayedMessageSchema,
FetchAgentsRequestMessageSchema,
FetchAgentHistoryRequestMessageSchema,
FetchRecentProviderSessionsRequestMessageSchema,
FetchWorkspacesRequestMessageSchema,
FetchAgentRequestMessageSchema,
DeleteAgentRequestMessageSchema,
@@ -2287,6 +2314,15 @@ export const FetchAgentHistoryResponseMessageSchema = z.object({
}),
});
export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
type: z.literal("fetch_recent_provider_sessions_response"),
payload: z.object({
requestId: z.string(),
entries: z.array(RecentProviderSessionDescriptorPayloadSchema),
filteredAlreadyImportedCount: z.number().int().nonnegative().optional(),
}),
});
export const FetchWorkspacesResponseMessageSchema = z.object({
type: z.literal("fetch_workspaces_response"),
payload: z.object({
@@ -3324,6 +3360,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
FetchAgentHistoryResponseMessageSchema,
FetchRecentProviderSessionsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
StartWorkspaceScriptResponseMessageSchema,
@@ -3454,6 +3491,9 @@ export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessa
export type FetchAgentHistoryResponseMessage = z.infer<
typeof FetchAgentHistoryResponseMessageSchema
>;
export type FetchRecentProviderSessionsResponseMessage = z.infer<
typeof FetchRecentProviderSessionsResponseMessageSchema
>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
@@ -3530,6 +3570,9 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
export type FetchAgentHistoryRequestMessage = z.infer<typeof FetchAgentHistoryRequestMessageSchema>;
export type FetchRecentProviderSessionsRequestMessage = z.infer<
typeof FetchRecentProviderSessionsRequestMessageSchema
>;
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
@@ -1,6 +1,7 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import {
RecentProviderSessionDescriptorPayloadSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema,
@@ -110,6 +111,125 @@ describe("workspace message schemas", () => {
expect(response.type).toBe("fetch_agent_history_response");
});
test("parses recent provider session descriptors without legacy handle fields", () => {
const parsed = RecentProviderSessionDescriptorPayloadSchema.parse({
providerId: "custom-codex",
providerLabel: "Custom Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
expect(parsed).toEqual({
providerId: "custom-codex",
providerLabel: "Custom Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
});
test("parses fetch_recent_provider_sessions request and response", () => {
const request = SessionInboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_request",
requestId: "req-recent-provider-sessions",
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
});
const response = SessionOutboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "my-claude",
providerLabel: "My Claude",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
},
});
expect(request.type).toBe("fetch_recent_provider_sessions_request");
expect(request.providers).toEqual(["my-claude"]);
expect(response.payload).toEqual({
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "my-claude",
providerLabel: "My Claude",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
});
});
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
const response = SessionOutboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [],
filteredAlreadyImportedCount: 3,
},
});
if (response.type !== "fetch_recent_provider_sessions_response") {
throw new Error("expected fetch_recent_provider_sessions_response");
}
expect(response.payload.filteredAlreadyImportedCount).toBe(3);
});
test("parses new and legacy import agent requests", () => {
const newRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId: "req-import-new",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
const legacyRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId: "req-import-legacy",
provider: "custom-codex",
sessionId: "thread-1",
cwd: "/tmp/repo",
});
expect(newRequest).toEqual({
type: "import_agent_request",
requestId: "req-import-new",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(legacyRequest).toEqual({
type: "import_agent_request",
requestId: "req-import-legacy",
provider: "custom-codex",
sessionId: "thread-1",
cwd: "/tmp/repo",
});
});
test("parses open_project_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "open_project_request",