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