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
+13 -6
View File
@@ -75,12 +75,19 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
- `npm run lint -- packages/app/src/components/message.tsx`
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
## Platform gating
@@ -0,0 +1,685 @@
/**
* @vitest-environment jsdom
*/
import React, { type ReactNode } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6, full: 9999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface1: "#111",
surface2: "#222",
surface3: "#333",
border: "#444",
borderAccent: "#555",
},
},
}));
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({
uniProps,
...rest
}: {
uniProps?: (theme: unknown) => Record<string, unknown>;
} & Record<string, unknown>) => {
const themed = uniProps ? uniProps(theme) : {};
return React.createElement(Component, { ...rest, ...themed });
},
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => () => null,
}));
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
title,
children,
testID,
}: {
visible: boolean;
title: string;
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{title}</h1>
{children}
</section>
) : null,
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
return actual;
});
const mockSnapshot = vi.hoisted(() => ({
current: {
entries: undefined as ProviderSnapshotEntry[] | undefined,
supportsSnapshot: false,
},
}));
vi.mock("@/hooks/use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries: mockSnapshot.current.entries,
isLoading: false,
isFetching: false,
isRefreshing: false,
error: null,
supportsSnapshot: mockSnapshot.current.supportsSnapshot,
refresh: vi.fn(),
refetchIfStale: vi.fn(),
}),
}));
interface RenderOptions {
visible?: boolean;
onClose?: () => void;
onImportedAgent?: (agentId: string) => void;
snapshot?: {
entries?: ProviderSnapshotEntry[];
supportsSnapshot?: boolean;
};
}
function renderSheet(
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
options?: RenderOptions,
) {
mockSnapshot.current = {
entries: options?.snapshot?.entries,
supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false,
};
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
visible={options?.visible ?? true}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
/>
</QueryClientProvider>,
);
}
function createRecentSessionsClient(
fetchRecentProviderSessions: Pick<
DaemonClient,
"fetchRecentProviderSessions"
>["fetchRecentProviderSessions"],
importAgent: Pick<DaemonClient, "importAgent">["importAgent"],
): Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent"> {
return { fetchRecentProviderSessions, importAgent };
}
function createImportedAgentSnapshot(id: string): Awaited<ReturnType<DaemonClient["importAgent"]>> {
return {
id,
provider: "custom-provider",
cwd: "/repo/paseo",
model: null,
createdAt: "2026-04-30T10:00:00.000Z",
updatedAt: "2026-04-30T10:00:00.000Z",
lastUserMessageAt: "2026-04-30T10:00:00.000Z",
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
};
}
function createProviderSessionEntry(
overrides?: Partial<FetchRecentProviderSessionEntry>,
): FetchRecentProviderSessionEntry {
return {
providerId: "custom-provider",
providerLabel: "Custom Agent",
providerHandleId: "provider-thread-1",
cwd: "/repo/paseo",
title: "Import me",
firstPromptPreview: "Import this external provider session",
lastPromptPreview: "Import this external provider session",
lastActivityAt: "2026-04-30T10:00:00.000Z",
...overrides,
};
}
const PROVIDER_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
};
function createSnapshotEntry(
provider: string,
overrides?: Partial<ProviderSnapshotEntry>,
): ProviderSnapshotEntry {
return {
provider,
status: "ready",
enabled: true,
label: PROVIDER_LABELS[provider] ?? provider,
...overrides,
};
}
describe("WorkspaceImportSheet", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("shows an update-host message when the daemon does not support provider snapshots", async () => {
const fetchRecentProviderSessions = vi.fn();
const importAgent = vi.fn();
renderSheet({ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>);
await screen.findByText("Update the host to import sessions.");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("shows a loading state while provider snapshot is loading", async () => {
const fetchRecentProviderSessions = vi.fn(
() => new Promise<Awaited<ReturnType<DaemonClient["fetchRecentProviderSessions"]>>>(() => {}),
);
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: undefined },
},
);
await screen.findByText("Loading recent sessions...");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("shows an empty state when there are no recent provider sessions to import", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("No recent sessions to import.");
});
it("shows the all-already-imported empty state when filteredAlreadyImportedCount is positive", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [],
filteredAlreadyImportedCount: 3,
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("All recent sessions are already imported.");
expect(screen.queryByText("No recent sessions to import.")).toBeNull();
});
it("shows a fetch error state when recent provider sessions cannot be loaded", async () => {
const fetchRecentProviderSessions = vi.fn(async () => {
throw new Error("recent sessions unavailable");
});
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Could not load recent sessions.");
});
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
vi.setSystemTime(new Date("2026-04-30T12:00:00.000Z"));
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
title: null,
firstPromptPreview: "Implement the importer sheet",
lastPromptPreview: "Make the rows readable and provider opaque",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["claude"],
limit: 15,
});
});
await screen.findByText("Claude Code");
screen.getByText("2h ago");
screen.getByText("Implement the importer sheet");
screen.getByText("Make the rows readable and provider opaque");
});
it("keeps cached rows visible and revalidates when reopened", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
title: "Cached importable session",
}),
],
}));
const importAgent = vi.fn();
const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockSnapshot.current = {
entries: [createSnapshotEntry("claude")],
supportsSnapshot: true,
};
function TestSheet({ visible }: { visible: boolean }) {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
visible={visible}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
onClose={vi.fn()}
onImportedAgent={vi.fn()}
/>
</QueryClientProvider>
);
}
const { rerender } = render(<TestSheet visible />);
await screen.findByText("Cached importable session");
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
rerender(<TestSheet visible={false} />);
fetchRecentProviderSessions.mockClear();
rerender(<TestSheet visible />);
await screen.findByText("Cached importable session");
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["claude"],
limit: 15,
});
});
});
it("imports a selected session by provider handle and reports the imported agent", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onClose = vi.fn();
const onImportedAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
onClose,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/repo/paseo",
});
});
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows an import error state without closing when selected session import fails", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
}));
const importAgent = vi.fn(async () => {
throw new Error("import unavailable");
});
const onClose = vi.fn();
const onImportedAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
onClose,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
await screen.findByText("Could not import selected session.");
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/repo/paseo",
});
expect(onImportedAgent).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
const fetchRecentProviderSessions = vi.fn(
async (options: { providers?: string[] } | undefined) => ({
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
entries: [
createProviderSessionEntry({
providerId: options?.providers?.[0] ?? "custom-provider",
providerLabel: options?.providers?.[0] ?? "Custom",
providerHandleId: `${options?.providers?.[0] ?? "custom-provider"}-thread`,
title: `Session ${options?.providers?.[0] ?? "all"}`,
lastActivityAt: "2026-04-30T10:00:00.000Z",
}),
],
}),
);
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: {
supportsSnapshot: true,
entries: [
createSnapshotEntry("claude"),
createSnapshotEntry("codex"),
createSnapshotEntry("opencode", { enabled: false }),
createSnapshotEntry("z-ai"),
],
},
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["claude"],
limit: 15,
});
});
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["codex"],
limit: 15,
});
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ providers: ["opencode"] }),
);
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ providers: ["z-ai"] }),
);
await screen.findByText("Session claude");
await screen.findByText("Session codex");
});
it("shows partial-failure note when one provider request fails but others succeed", async () => {
const fetchRecentProviderSessions = vi.fn(
async (options: { providers?: string[] } | undefined) => {
const provider = options?.providers?.[0];
if (provider === "claude") {
throw new Error("claude offline");
}
return {
requestId: `recent-${provider ?? "all"}`,
entries: [
createProviderSessionEntry({
providerId: provider ?? "custom-provider",
providerHandleId: `${provider}-thread`,
providerLabel: provider ?? "Custom",
title: `Session ${provider}`,
}),
],
};
},
);
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: {
supportsSnapshot: true,
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
},
},
);
await screen.findByText("Session codex");
await screen.findByText("Could not load sessions for Claude Code.");
});
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
const fetchRecentProviderSessions = vi.fn(
async (options: { providers?: string[] } | undefined) => {
const provider = options?.providers?.[0] ?? "claude";
return {
requestId: `recent-${provider}`,
entries: [
createProviderSessionEntry({
providerId: provider,
providerLabel: provider === "claude" ? "Claude Code" : "Codex",
providerHandleId: `${provider}-thread`,
title: `Session ${provider}`,
lastActivityAt:
provider === "claude" ? "2026-04-30T09:00:00.000Z" : "2026-04-30T10:00:00.000Z",
}),
],
};
},
);
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: {
supportsSnapshot: true,
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
},
},
);
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
screen.getByText("Session claude");
screen.getByText("Session codex");
});
it("does not render filter badges when only one importable provider is enabled", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-codex",
entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: {
supportsSnapshot: true,
entries: [
createSnapshotEntry("codex"),
createSnapshotEntry("claude", { enabled: false }),
],
},
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
const fetchRecentProviderSessions = vi.fn();
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: {
supportsSnapshot: true,
entries: [
createSnapshotEntry("claude", { enabled: false }),
createSnapshotEntry("codex", { enabled: false }),
createSnapshotEntry("opencode", { enabled: false }),
createSnapshotEntry("z-ai"),
],
},
},
);
await screen.findByText("No importable providers are enabled.");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,615 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const activityIndicatorColorMapping = (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
});
type RecentProviderSessionsClient = Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>;
interface WorkspaceImportSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
workspaceDirectory: string | null;
onClose: () => void;
onImportedAgent: (agentId: string) => void;
}
type RecentSessionsResponse = Awaited<
ReturnType<RecentProviderSessionsClient["fetchRecentProviderSessions"]>
>;
interface SessionsQueryConfig {
queryKey: ReadonlyArray<string | null>;
enabled: boolean;
queryFn: () => Promise<RecentSessionsResponse>;
}
interface SessionsQueryResult {
data: RecentSessionsResponse | undefined;
isError: boolean;
isLoading: boolean;
isPending: boolean;
}
function resolveProvidersToFetch(
supportsSnapshot: boolean,
snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
): AgentProvider[] | null {
// COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
// providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
// we render an "update host" empty state instead of degrading. Drop this gate
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
if (!supportsSnapshot) return null;
if (!snapshotEntries) return null;
return snapshotEntries
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
.map((entry) => entry.provider);
}
function buildProviderLabelMap(
snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
): Map<string, string> {
const map = new Map<string, string>();
if (!snapshotEntries) return map;
for (const entry of snapshotEntries) {
if (entry.label) {
map.set(entry.provider, entry.label);
}
}
return map;
}
function buildSessionsQueriesConfig(args: {
providersToFetch: AgentProvider[] | null;
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
workspaceDirectory: string | null;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client && workspaceDirectory);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
cwd: workspaceDirectory,
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
},
}));
}
function aggregateSessionEntries(
queries: ReadonlyArray<SessionsQueryResult>,
): FetchRecentProviderSessionEntry[] {
const seen = new Set<string>();
const collected: FetchRecentProviderSessionEntry[] = [];
for (const query of queries) {
if (!query.data) continue;
for (const entry of query.data.entries) {
const key = `${entry.providerId}:${entry.providerHandleId}`;
if (seen.has(key)) continue;
seen.add(key);
collected.push(entry);
}
}
collected.sort(
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
);
return collected;
}
function sumFilteredAlreadyImportedCount(queries: ReadonlyArray<SessionsQueryResult>): number {
let total = 0;
for (const query of queries) {
total += query.data?.filteredAlreadyImportedCount ?? 0;
}
return total;
}
function collectErroredProviderLabels(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): string[] {
if (providersToFetch === null) return [];
const labels: string[] = [];
for (let index = 0; index < queries.length; index++) {
if (queries[index]?.isError) {
const provider = providersToFetch[index];
labels.push(providerLabelById.get(provider) ?? provider);
}
}
return labels;
}
function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
const title = entry.title?.trim();
if (title) {
return title;
}
const firstPromptPreview = entry.firstPromptPreview?.trim();
if (firstPromptPreview) {
return firstPromptPreview;
}
return "Untitled session";
}
function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
}
interface SheetStatusMessagesProps {
isClientReady: boolean;
isSnapshotUnsupported: boolean;
hasNoImportableProviders: boolean;
isLoadingSessions: boolean;
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
showEmptyState: boolean;
allAlreadyImported: boolean;
}
function SheetStatusMessages({
isClientReady,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
allQueriesErrored,
erroredProviderLabels,
importErrored,
showEmptyState,
allAlreadyImported,
}: SheetStatusMessagesProps) {
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a workspace to import agents.</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
}
return (
<>
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
) : null}
{isLoadingSessions ? (
<View style={styles.statusRow}>
<ThemedActivityIndicator size="small" uniProps={activityIndicatorColorMapping} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
</View>
) : null}
{allQueriesErrored ? (
<Text style={styles.statusText}>Could not load recent sessions.</Text>
) : null}
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
<Text style={styles.statusText}>
Could not load sessions for {erroredProviderLabels.join(", ")}.
</Text>
) : null}
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
) : null}
{showEmptyState ? (
<Text style={styles.statusText}>
{allAlreadyImported
? "All recent sessions are already imported."
: "No recent sessions to import."}
</Text>
) : null}
</>
);
}
interface ProviderFilterBadgeProps {
testID: string;
label: string;
selected: boolean;
onPress: () => void;
}
function ProviderFilterBadge({ testID, label, selected, onPress }: ProviderFilterBadgeProps) {
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const containerStyle = useMemo(
() => [
styles.filterBadge,
selected ? styles.filterBadgeSelected : styles.filterBadgeUnselected,
],
[selected],
);
const textStyle = useMemo(
() => [
styles.filterBadgeLabel,
selected ? styles.filterBadgeLabelSelected : styles.filterBadgeLabelUnselected,
],
[selected],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onPress}
style={containerStyle}
testID={testID}
>
<Text style={textStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
function ProviderFilterBadgeItem({
provider,
label,
selected,
onSelect,
}: {
provider: string;
label: string;
selected: boolean;
onSelect: (provider: string) => void;
}) {
const handlePress = useCallback(() => onSelect(provider), [onSelect, provider]);
return (
<ProviderFilterBadge
testID={`workspace-import-filter-${provider}`}
label={label}
selected={selected}
onPress={handlePress}
/>
);
}
function WorkspaceImportSheetRow({
entry,
disabled,
importing,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
const ProviderIcon = getProviderIcon(entry.providerId);
const accessibilityState = useMemo(
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
[disabled],
);
const handlePress = useCallback(() => {
onImportSession(entry);
}, [entry, onImportSession]);
return (
<Pressable
disabled={disabled}
onPress={handlePress}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={styles.row}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowHeader}>
<View style={styles.rowProvider}>
<ProviderIcon size={theme.fontSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.providerLabel} numberOfLines={1}>
{entry.providerLabel}
</Text>
</View>
<Text style={styles.lastActivity}>{lastActivity}</Text>
</View>
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.promptPreview} numberOfLines={2}>
{promptPreview}
</Text>
{importing ? <Text style={styles.importingText}>Importing...</Text> : null}
</Pressable>
);
}
export function WorkspaceImportSheet({
visible,
client,
serverId,
workspaceDirectory,
onClose,
onImportedAgent,
}: WorkspaceImportSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
enabled: visible,
});
const providersToFetch = useMemo(
() => resolveProvidersToFetch(supportsSnapshot, snapshotEntries),
[supportsSnapshot, snapshotEntries],
);
const providerLabelById = useMemo(
() => buildProviderLabelMap(snapshotEntries),
[snapshotEntries],
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
);
const queriesConfig = useMemo(
() =>
buildSessionsQueriesConfig({
providersToFetch,
sessionsQueryRoot,
visible,
client,
workspaceDirectory,
}),
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
);
const queries = useQueries({ queries: queriesConfig });
const aggregatedEntries = useMemo(() => aggregateSessionEntries(queries), [queries]);
const totalAlreadyImportedCount = useMemo(
() => sumFilteredAlreadyImportedCount(queries),
[queries],
);
const badgeProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
useEffect(() => {
if (!visible || (selectedProvider !== null && !badgeProviders.includes(selectedProvider))) {
setSelectedProvider(null);
}
}, [visible, badgeProviders, selectedProvider]);
const visibleEntries = useMemo(() => {
if (selectedProvider === null) return aggregatedEntries;
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
}, [aggregatedEntries, selectedProvider]);
const handleSelectAll = useCallback(() => setSelectedProvider(null), []);
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: workspaceDirectory,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent(agent.id);
},
});
const importingSessionKey =
importMutation.isPending && importMutation.variables
? `${importMutation.variables.providerId}:${importMutation.variables.providerHandleId}`
: null;
const handleImportSession = useCallback(
(entry: FetchRecentProviderSessionEntry) => {
importMutation.mutate(entry);
},
[importMutation],
);
const erroredProviderLabels = useMemo(
() => collectErroredProviderLabels(providersToFetch, queries, providerLabelById),
[queries, providersToFetch, providerLabelById],
);
const isSnapshotUnsupported = !supportsSnapshot;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
const isQueryingProviders = queries.length > 0;
const isLoadingSessions =
isWaitingForSnapshot ||
(isQueryingProviders && queries.some((query) => query.isLoading || query.isPending));
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
const showEmptyState =
!isLoadingSessions &&
!allQueriesErrored &&
isQueryingProviders &&
allQueriesSettled &&
aggregatedEntries.length === 0;
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
const showBadges = badgeProviders.length > 1;
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Import agent"
testID="workspace-import-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
{showBadges ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
testID="workspace-import-filters"
>
<ProviderFilterBadge
testID="workspace-import-filter-all"
label="All"
selected={selectedProvider === null}
onPress={handleSelectAll}
/>
{badgeProviders.map((provider) => (
<ProviderFilterBadgeItem
key={provider}
provider={provider}
label={providerLabelById.get(provider) ?? provider}
selected={selectedProvider === provider}
onSelect={setSelectedProvider}
/>
))}
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client && workspaceDirectory)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
showEmptyState={showEmptyState}
allAlreadyImported={allAlreadyImported}
/>
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<WorkspaceImportSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
onImportSession={handleImportSession}
/>
))}
</View>
) : null}
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
list: {
gap: theme.spacing[2],
},
row: {
gap: theme.spacing[1],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
},
rowHeader: {
minWidth: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
rowProvider: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
providerLabel: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
filterRow: {
flexDirection: "row",
gap: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
filterBadge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
},
filterBadgeSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
filterBadgeUnselected: {
backgroundColor: "transparent",
borderColor: theme.colors.border,
},
filterBadgeLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
filterBadgeLabelSelected: {
color: theme.colors.foreground,
},
filterBadgeLabelUnselected: {
color: theme.colors.foregroundMuted,
},
lastActivity: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
promptPreview: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 19,
},
importingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -24,6 +24,7 @@ import {
Ellipsis,
EllipsisVertical,
Globe,
Import as ImportIcon,
PanelRight,
RotateCw,
Settings,
@@ -57,6 +58,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -174,6 +176,7 @@ const ThemedX = withUnistyles(X);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedImport = withUnistyles(ImportIcon);
const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
@@ -186,6 +189,7 @@ const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
const GATED_WORKSPACE_HEADER_LEFT = <SidebarMenuToggle />;
@@ -777,14 +781,17 @@ interface WorkspaceHeaderMenuProps {
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuImportIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
@@ -811,14 +818,17 @@ function WorkspaceHeaderMenu({
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuImportIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
@@ -865,6 +875,14 @@ function WorkspaceHeaderMenu({
New browser tab
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="workspace-header-import-agent"
leading={menuImportIcon}
disabled={importAgentDisabled}
onSelect={onOpenImportSheet}
>
Import agent...
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={menuCopyIcon}
@@ -912,14 +930,17 @@ interface WorkspaceHeaderTitleBarProps {
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuImportIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
@@ -938,14 +959,17 @@ function WorkspaceHeaderTitleBar({
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuImportIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
@@ -983,14 +1007,17 @@ function WorkspaceHeaderTitleBar({
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={importAgentDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon}
menuImportIcon={menuImportIcon}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal}
onCreateBrowser={onCreateBrowser}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
@@ -1388,6 +1415,14 @@ function WorkspaceScreenContent({
);
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
const openImportSheet = useCallback(() => {
setIsImportSheetVisible(true);
}, []);
const closeImportSheet = useCallback(() => {
setIsImportSheetVisible(false);
}, []);
// Warm the global provider snapshot so the model picker is ready when opened.
useProvidersSnapshot(normalizedServerId, {
@@ -1933,6 +1968,18 @@ function WorkspaceScreenContent({
},
[focusWorkspaceTab, persistenceKey],
);
const handleImportedAgent = useCallback(
(agentId: string) => {
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "agent", agentId });
if (tabId) {
navigateToTabId(tabId);
}
},
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
@@ -3212,14 +3259,17 @@ function WorkspaceScreenContent({
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuImportIcon={MENU_IMPORT_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
@@ -3301,6 +3351,14 @@ function WorkspaceScreenContent({
/>
) : null}
</View>
<WorkspaceImportSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
workspaceDirectory={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
</View>
</WorkspaceFocusProvider>
)
@@ -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();
+58 -5
View File
@@ -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 } : {}),
});
@@ -1090,6 +1090,17 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
return new TestAgentSession(config);
}
async listModels() {
return [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
];
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
@@ -1156,6 +1167,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
},
});
expect(client.lastResumeOverrides).toMatchObject({
model: "gpt-5.4",
modeId: "auto",
systemPrompt: "new prompt",
mcpServers: {
@@ -5506,3 +5518,109 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull();
}, 10_000);
class RecordingPersistedAgentsClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
calls = 0;
constructor(public readonly provider: AgentProvider) {}
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
throw new Error(`unexpected createSession for ${this.provider}`);
}
async resumeSession(): Promise<AgentSession> {
throw new Error(`unexpected resumeSession for ${this.provider}`);
}
async listModels() {
return [];
}
async listPersistedAgents(): Promise<PersistedAgentDescriptor[]> {
this.calls += 1;
return [
{
provider: this.provider,
sessionId: `${this.provider}-session`,
cwd: "/tmp/recent",
title: null,
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
persistence: { provider: this.provider, sessionId: `${this.provider}-session` },
timeline: [],
},
];
}
}
test.each([
[
"disabled",
"claude",
"codex",
{
claude: { enabled: true, derivedFromProviderId: null },
codex: { enabled: false, derivedFromProviderId: null },
},
],
[
"derived",
"claude",
"zai",
{
claude: { enabled: true, derivedFromProviderId: null },
zai: { enabled: true, derivedFromProviderId: "claude" },
},
],
[
"outside importable allowlist",
"claude",
"gemini",
{
claude: { enabled: true, derivedFromProviderId: null },
gemini: { enabled: true, derivedFromProviderId: null },
},
],
])(
"listImportablePersistedAgents skips %s providers in fan-out",
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
const includedClient = new RecordingPersistedAgentsClient(includedProvider);
const skippedClient = new RecordingPersistedAgentsClient(skippedProvider);
const manager = new AgentManager({
clients: { [includedProvider]: includedClient, [skippedProvider]: skippedClient },
providerDefinitions,
logger,
});
const result = await manager.listImportablePersistedAgents();
expect(includedClient.calls).toBe(1);
expect(skippedClient.calls).toBe(0);
expect(result.map((d) => d.provider)).toEqual([includedProvider]);
},
);
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
const claudeClient = new RecordingPersistedAgentsClient("claude");
const codexClient = new RecordingPersistedAgentsClient("codex");
const manager = new AgentManager({
clients: { claude: claudeClient, codex: codexClient },
providerDefinitions: {
claude: { enabled: true, derivedFromProviderId: null },
codex: { enabled: true, derivedFromProviderId: null },
},
logger,
});
const result = await manager.listImportablePersistedAgents({
providerFilter: new Set(["claude"]),
});
expect(claudeClient.calls).toBe(1);
expect(codexClient.calls).toBe(0);
expect(result.map((d) => d.provider)).toEqual(["claude"]);
});
@@ -52,6 +52,7 @@ import {
} from "./agent-stream-coalescer.js";
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -129,8 +130,12 @@ export interface SubscribeOptions {
replayState?: boolean;
}
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
provider?: AgentProvider;
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
/**
* When set, only providers in this set are scanned, in addition to the
* built-in importable allowlist + enabled + non-derived rules.
*/
providerFilter?: Set<string>;
};
export type AgentAttentionCallback = (params: {
@@ -152,6 +157,7 @@ interface AgentManagerRescueTimeouts {
interface ProviderEnabledFlag {
enabled: boolean;
derivedFromProviderId?: string | null;
}
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
@@ -416,6 +422,7 @@ function buildExplicitTimelineSeedForRegister(
export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDerivedFromId = new Map<AgentProvider, string | null>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
@@ -471,6 +478,7 @@ export class AgentManager {
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
if (definition) {
this.providerEnabled.set(provider, definition.enabled);
this.providerDerivedFromId.set(provider, definition.derivedFromProviderId ?? null);
}
}
for (const [provider, client] of Object.entries(input.clients)) {
@@ -592,24 +600,21 @@ export class AgentManager {
.map((agent) => Object.assign({}, agent));
}
async listPersistedAgents(
options?: PersistedAgentQueryOptions,
async listImportablePersistedAgents(
options?: ImportablePersistedAgentQueryOptions,
): Promise<PersistedAgentDescriptor[]> {
if (options?.provider) {
const client = this.requireClient(options.provider);
if (!client.listPersistedAgents) {
return [];
}
return client.listPersistedAgents({ limit: options.limit });
}
const providerEntries = Array.from(this.clients.entries()).filter(
([, client]) => !!client.listPersistedAgents,
([provider, client]) =>
!!client.listPersistedAgents &&
this.isProviderImportable(provider, options?.providerFilter),
);
const descriptorLists = await Promise.all(
providerEntries.map(async ([provider, client]) => {
try {
return await client.listPersistedAgents!({ limit: options?.limit });
return await client.listPersistedAgents!({
limit: options?.limit,
cwd: options?.cwd,
});
} catch (error) {
this.logger.warn(
{ err: error, provider },
@@ -627,6 +632,25 @@ export class AgentManager {
.slice(0, limit);
}
private isProviderImportable(
provider: AgentProvider,
providerFilter: Set<string> | undefined,
): boolean {
if (!IMPORTABLE_PROVIDERS.includes(provider as (typeof IMPORTABLE_PROVIDERS)[number])) {
return false;
}
if (this.providerEnabled.get(provider) === false) {
return false;
}
if (this.providerDerivedFromId.get(provider) != null) {
return false;
}
if (providerFilter && !providerFilter.has(provider)) {
return false;
}
return true;
}
async findPersistedAgent(
provider: AgentProvider,
sessionId: string,
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js";
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
import {
toAgentPayload,
toRecentProviderSessionDescriptorPayload,
toStoredAgentRecord,
type ManagedAgent,
} from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentFeature,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
@@ -392,3 +398,70 @@ describe("toAgentPayload", () => {
expect(payload.features).toEqual(features);
});
});
describe("toRecentProviderSessionDescriptorPayload", () => {
it("projects persisted descriptors to provider-opaque public recent sessions", () => {
const descriptor: PersistedAgentDescriptor = {
provider: "codex-custom",
sessionId: "legacy-session-id",
cwd: "/tmp/project",
title: "Import me",
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
persistence: {
provider: "codex-custom",
sessionId: "legacy-session-id",
nativeHandle: "provider-native-handle",
},
timeline: [
{ type: "assistant_message", text: "Ready" },
{ type: "user_message", text: " First prompt\n\nwith spacing " },
{ type: "user_message", text: "Second prompt" },
],
};
const payload = toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: "Custom Codex",
});
expect(payload).toEqual({
providerId: "codex-custom",
providerLabel: "Custom Codex",
providerHandleId: "provider-native-handle",
cwd: "/tmp/project",
title: "Import me",
firstPromptPreview: "First prompt with spacing",
lastPromptPreview: "Second prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
expect(payload).not.toHaveProperty("providerKind");
expect(payload).not.toHaveProperty("sessionId");
expect(payload).not.toHaveProperty("nativeHandle");
});
it("falls back to persistence session id when no provider native handle exists", () => {
const descriptor: PersistedAgentDescriptor = {
provider: "claude-custom",
sessionId: "descriptor-session-id",
cwd: "/tmp/project",
title: null,
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
persistence: {
provider: "claude-custom",
sessionId: "persistence-session-id",
},
timeline: [],
};
expect(
toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: "Custom Claude",
}),
).toMatchObject({
providerId: "claude-custom",
providerLabel: "Custom Claude",
providerHandleId: "persistence-session-id",
firstPromptPreview: null,
lastPromptPreview: null,
});
});
});
@@ -1,4 +1,8 @@
import type { AgentListItemPayload, AgentSnapshotPayload } from "../messages.js";
import type {
AgentListItemPayload,
AgentSnapshotPayload,
RecentProviderSessionDescriptorPayload,
} from "../messages.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
@@ -10,7 +14,9 @@ import type {
AgentProvider,
AgentSessionConfig,
AgentRuntimeInfo,
AgentTimelineItem,
AgentUsage,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
@@ -23,6 +29,12 @@ interface ProjectionOptions {
internal?: boolean;
}
interface RecentProviderSessionProjectionOptions {
providerLabel: string;
}
const PROMPT_PREVIEW_MAX_LENGTH = 160;
function normalizeThinkingOptionId(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
@@ -247,6 +259,24 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
};
}
export function toRecentProviderSessionDescriptorPayload(
descriptor: PersistedAgentDescriptor,
options: RecentProviderSessionProjectionOptions,
): RecentProviderSessionDescriptorPayload {
const promptPreviews = collectPromptPreviews(descriptor.timeline);
return {
providerId: descriptor.provider,
providerLabel: options.providerLabel,
providerHandleId: descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId,
cwd: descriptor.cwd,
title: descriptor.title,
firstPromptPreview: promptPreviews[0] ?? null,
lastPromptPreview: promptPreviews.at(-1) ?? null,
lastActivityAt: descriptor.lastActivityAt.toISOString(),
};
}
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
const timestamps = [record.updatedAt, record.lastActivityAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -264,6 +294,26 @@ export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): s
return timestamps[0].raw;
}
function collectPromptPreviews(timeline: readonly AgentTimelineItem[]): string[] {
return timeline.flatMap((item) => {
if (item.type !== "user_message") {
return [];
}
const preview = normalizePromptPreview(item.text);
return preview ? [preview] : [];
});
}
function normalizePromptPreview(text: string): string | null {
const normalized = text.trim().replace(/\s+/g, " ");
if (!normalized) {
return null;
}
return normalized.length > PROMPT_PREVIEW_MAX_LENGTH
? normalized.slice(0, PROMPT_PREVIEW_MAX_LENGTH)
: normalized;
}
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (Object.prototype.hasOwnProperty.call(config, "title")) {
@@ -427,6 +427,13 @@ export interface AgentSlashCommand {
export interface ListPersistedAgentsOptions {
limit?: number;
/**
* Optional cwd hint. Providers that can cheaply pre-filter persisted
* sessions by working directory should do so before doing expensive
* work like fetching turn timelines. Providers that can't filter
* cheaply may ignore this hint.
*/
cwd?: string;
}
export interface PersistedAgentDescriptor {
@@ -0,0 +1,226 @@
import { expect, test, vi } from "vitest";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
import type { PersistedAgentDescriptor } from "./agent-sdk-types.js";
import {
ImportSessionsRequestError,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./import-sessions.js";
function makeDescriptor(args: {
provider?: string;
sessionId: string;
nativeHandle?: string;
cwd?: string;
title?: string | null;
lastActivityAt: string;
firstPrompt?: string;
lastPrompt?: string;
}): PersistedAgentDescriptor {
const provider = args.provider ?? "codex";
const cwd = args.cwd ?? "/tmp/project";
return {
provider,
sessionId: args.sessionId,
cwd,
title: args.title ?? null,
lastActivityAt: new Date(args.lastActivityAt),
persistence: {
provider,
sessionId: args.sessionId,
...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
metadata: { provider, cwd },
},
timeline: [
...(args.firstPrompt ? [{ type: "user_message" as const, text: args.firstPrompt }] : []),
...(args.lastPrompt ? [{ type: "user_message" as const, text: args.lastPrompt }] : []),
],
};
}
function makeRequest(
overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {},
): FetchRecentProviderSessionsRequestMessage {
return {
type: "fetch_recent_provider_sessions_request",
requestId: "recent-provider-sessions",
...overrides,
};
}
test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => {
const cwd = "/tmp/project";
const descriptors = [
makeDescriptor({
sessionId: "outside-cwd",
nativeHandle: "outside-cwd-handle",
cwd: "/tmp/elsewhere",
title: "Outside cwd",
lastActivityAt: "2026-04-30T12:05:00.000Z",
}),
makeDescriptor({
sessionId: "stored-session",
nativeHandle: "stored-handle",
cwd,
title: "Already stored",
lastActivityAt: "2026-04-30T12:04:00.000Z",
}),
makeDescriptor({
sessionId: "older-session",
nativeHandle: "older-handle",
cwd,
title: "Older than since",
lastActivityAt: "2026-04-29T23:59:59.000Z",
}),
makeDescriptor({
sessionId: "newer-session",
nativeHandle: "newer-handle",
cwd,
title: "Newer import",
lastActivityAt: "2026-04-30T12:02:00.000Z",
firstPrompt: "newer first prompt",
lastPrompt: "newer last prompt",
}),
makeDescriptor({
sessionId: "second-session",
nativeHandle: "second-handle",
cwd,
title: "Second import",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "second prompt",
}),
makeDescriptor({
sessionId: "third-session",
nativeHandle: "third-handle",
cwd,
title: "Third import",
lastActivityAt: "2026-04-30T11:59:00.000Z",
firstPrompt: "third prompt",
}),
makeDescriptor({
sessionId: "live-session",
nativeHandle: "live-handle",
cwd,
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
}),
];
const listImportablePersistedAgents = vi.fn(async () => descriptors);
const agentManager = {
listAgents: () =>
[
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
},
},
] as ManagedAgent[],
listImportablePersistedAgents,
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
const agentStorage = {
list: async () => [
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "stored-session",
nativeHandle: "stored-handle",
},
} as StoredAgentRecord,
],
} satisfies Pick<AgentStorage, "list">;
const result = await listImportableProviderSessions({
request: makeRequest({
cwd,
providers: ["codex"],
since: "2026-04-30T00:00:00.000Z",
limit: 2,
}),
agentManager,
agentStorage,
providerRegistry: { codex: { label: "Codex" } },
});
expect(listImportablePersistedAgents).toHaveBeenCalledWith({
limit: 200,
providerFilter: new Set(["codex"]),
cwd,
});
expect(result).toEqual({
filteredAlreadyImportedCount: 2,
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "newer-handle",
cwd,
title: "Newer import",
firstPromptPreview: "newer first prompt",
lastPromptPreview: "newer last prompt",
lastActivityAt: "2026-04-30T12:02:00.000Z",
},
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "second-handle",
cwd,
title: "Second import",
firstPromptPreview: "second prompt",
lastPromptPreview: "second prompt",
lastActivityAt: "2026-04-30T12:00:00.000Z",
},
],
});
});
test("listImportableProviderSessions rejects invalid since values", async () => {
await expect(
listImportableProviderSessions({
request: makeRequest({ since: "not-a-date" }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => [],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
providerRegistry: {},
}),
).rejects.toMatchObject(
new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since"),
);
});
test("normalizeImportAgentRequest accepts new and legacy import handle shapes", () => {
expect(
normalizeImportAgentRequest({
type: "import_agent_request",
requestId: "new-shape",
providerId: "custom-codex",
providerHandleId: "thread-1",
}),
).toEqual({
requestId: "new-shape",
provider: "custom-codex",
providerHandleId: "thread-1",
});
expect(
normalizeImportAgentRequest({
type: "import_agent_request",
requestId: "legacy-shape",
provider: "codex",
sessionId: "thread-2",
}),
).toEqual({
requestId: "legacy-shape",
provider: "codex",
providerHandleId: "thread-2",
});
});
@@ -0,0 +1,160 @@
import type { z } from "zod";
import type { ProviderDefinition } from "./provider-registry.js";
import type { AgentManager } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentPersistenceHandle,
AgentProvider,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
import type {
FetchRecentProviderSessionsRequestMessage,
ImportAgentRequestMessageSchema,
RecentProviderSessionDescriptorPayload,
} from "../../shared/messages.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
export interface NormalizedImportAgentRequest {
provider: string;
providerHandleId: string;
cwd?: string;
labels?: Record<string, string>;
requestId: string;
}
export class ImportSessionsRequestError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "ImportSessionsRequestError";
}
}
export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
agentStorage: Pick<AgentStorage, "list">;
providerRegistry: Record<string, Pick<ProviderDefinition, "label"> | undefined>;
}
export interface ListImportableProviderSessionsResult {
entries: RecentProviderSessionDescriptorPayload[];
filteredAlreadyImportedCount: number;
}
// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape
// alongside the new {providerId, providerHandleId} shape. Old clients
// (< target daemon floor) send the legacy fields. Drop the fallbacks and the
// .optional() in messages.ts when the supported client floor is >= the daemon
// version that ships the new shape (target: 2026-11-08).
export function normalizeImportAgentRequest(
msg: ImportAgentRequestMessage,
): NormalizedImportAgentRequest | { error: string } {
const provider = msg.providerId ?? msg.provider;
const providerHandleId = msg.providerHandleId ?? msg.sessionId;
if (!provider || !providerHandleId) {
return { error: "Import requires providerId and providerHandleId" };
}
return {
provider,
providerHandleId,
cwd: msg.cwd,
labels: msg.labels,
requestId: msg.requestId,
};
}
export async function listImportableProviderSessions(
input: ListImportableProviderSessionsInput,
): Promise<ListImportableProviderSessionsResult> {
const { request, agentManager, agentStorage, providerRegistry } = input;
const limit = request.limit ?? 20;
const sinceTimestamp = parseRecentProviderSessionsSince(request.since);
const providerFilter = request.providers ? new Set(request.providers) : undefined;
const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage);
const descriptors = await agentManager.listImportablePersistedAgents({
limit: 200,
providerFilter,
cwd: request.cwd,
});
let filteredAlreadyImportedCount = 0;
const candidates: PersistedAgentDescriptor[] = [];
for (const descriptor of descriptors) {
if (request.cwd && descriptor.cwd !== request.cwd) {
continue;
}
if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) {
continue;
}
const providerHandleId =
descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId;
if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) {
filteredAlreadyImportedCount += 1;
continue;
}
candidates.push(descriptor);
}
const entries = candidates
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
.slice(0, limit)
.map((descriptor) =>
toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: providerRegistry[descriptor.provider]?.label ?? descriptor.provider,
}),
);
return { entries, filteredAlreadyImportedCount };
}
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
if (!since) {
return null;
}
const timestamp = Date.parse(since);
if (Number.isNaN(timestamp)) {
throw new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since");
}
return timestamp;
}
async function collectImportedProviderSessionHandles(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,
): Promise<Set<string>> {
const handles = new Set<string>();
for (const agent of agentManager.listAgents()) {
collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence);
}
for (const record of await agentStorage.list()) {
collectProviderSessionHandleKeys(handles, record.provider, record.persistence);
}
return handles;
}
function toProviderSessionHandleKey(provider: string, providerHandleId: string): string {
return `${provider}\0${providerHandleId}`;
}
function collectProviderSessionHandleKeys(
target: Set<string>,
provider: AgentProvider | StoredAgentRecord["provider"] | string,
persistence: AgentPersistenceHandle | null | undefined,
): void {
if (!persistence) {
return;
}
target.add(toProviderSessionHandleKey(provider, persistence.sessionId));
if (persistence.nativeHandle) {
target.add(toProviderSessionHandleKey(provider, persistence.nativeHandle));
}
}
@@ -46,11 +46,19 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
enabled: boolean;
/**
* The id of another *registered* provider this one extends (e.g. a Z.AI
* profile that extends "claude"). null for built-in providers and for
* generic ACP providers (which only extend the literal "acp" sentinel).
*/
derivedFromProviderId: string | null;
createClient: (logger: Logger) => AgentClient;
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
}
export { IMPORTABLE_PROVIDERS } from "../../shared/importable-providers.js";
export interface BuildProviderRegistryOptions {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
@@ -70,6 +78,7 @@ interface ResolvedProvider {
profileModels: ProviderProfileModel[];
additionalModels: ProviderProfileModel[];
enabled: boolean;
derivedFromProviderId: string | null;
createBaseClient: (logger: Logger) => AgentClient;
}
@@ -375,6 +384,7 @@ function createRegistryEntry(
return {
...resolved.definition,
enabled: resolved.enabled,
derivedFromProviderId: resolved.derivedFromProviderId,
createClient: (providerLogger: Logger) =>
createResolvedProviderClient(providerLogger, provider, resolved),
fetchModels: async (options: ListModelsOptions) =>
@@ -441,6 +451,7 @@ function buildResolvedBuiltinProviders(
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
enabled: override?.enabled !== false,
derivedFromProviderId: null,
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
@@ -487,6 +498,7 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: null,
createBaseClient: (logger) =>
new GenericACPAgentClient({
logger,
@@ -517,6 +529,7 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: override.extends,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
});
}
@@ -15,6 +15,7 @@ import type {
} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
CodexAppServerAgentClient,
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -1656,3 +1657,62 @@ describe("Codex app-server provider", () => {
);
});
});
describe("Codex persisted sessions", () => {
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const allThreads = [
{
id: "thread-a1",
cwd: "/workspace/project-a",
preview: "First A session",
createdAt: 1000,
updatedAt: 2000,
},
{
id: "thread-a2",
cwd: "/workspace/project-a",
preview: "Second A session",
createdAt: 1500,
updatedAt: 2500,
},
{
id: "thread-b1",
cwd: "/workspace/project-b",
preview: "B session",
createdAt: 3000,
updatedAt: 4000,
},
];
const fakeClient = {
request: async (method: string) => {
if (method === "thread/list") return { data: allThreads };
if (method === "thread/read") return { thread: { turns: [] } };
return {};
},
notify: () => {},
dispose: async () => {},
};
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
_createCodexClient: () => fakeClient,
});
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
};
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });
expect(descriptors.map((d) => d.sessionId).sort()).toEqual(["thread-a1", "thread-a2"]);
expect(descriptors.every((d) => d.cwd === "/workspace/project-a")).toBe(true);
});
});
@@ -170,8 +170,18 @@ const CODEX_MODES: AgentMode[] = [
const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown): Promise<unknown>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
) => CodexAppServerClientLike;
}
const MODE_PRESETS: Record<
@@ -703,6 +713,20 @@ const CodexModelListResponseSchema = z.object({
.optional(),
});
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
): Array<Record<string, unknown>> {
if (!cwd) {
return threads;
}
// thread/list rows carry an optional cwd. The descriptor builder later
// falls back to process.cwd() if the field is missing, so we only match
// here when the row genuinely carries a cwd string — otherwise threads
// with no cwd would falsely match the daemon's own cwd.
return threads.filter((thread) => typeof thread.cwd === "string" && thread.cwd === cwd);
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
@@ -1731,7 +1755,7 @@ async function loadCodexThreadHistoryTimeline(params: {
return timeline;
}
function readCodexThread(client: CodexAppServerClient, threadId: string): Promise<unknown> {
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
@@ -4769,6 +4793,13 @@ export class CodexAppServerAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
if (options?.persistSession === false) {
this.logger.debug(
"Codex app-server does not expose an ephemeral-session option; persistSession=false is currently a no-op",
);
// TODO: Honor persistSession=false if app-server adds support, or route
// utility generations through `codex exec --ephemeral` in a larger change.
}
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const goalsEnabled = await this.resolveGoalsEnabled();
const session = new CodexAppServerAgentSession(
@@ -4814,15 +4845,23 @@ export class CodexAppServerAgentClient implements AgentClient {
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
const client =
this.deps._createCodexClient?.(child, this.logger) ??
new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
const response = toObjectRecord(await client.request("thread/list", { limit }));
const threads = Array.isArray(response?.data) ? response.data : [];
// thread/list returns the cheap `cwd` field. When the caller supplied
// a cwd hint we filter here so the per-thread `thread/read includeTurns`
// hydration below only runs for matching threads. Fetch a wider window
// when filtering since most threads will be from other cwds.
const listLimit = options?.cwd ? Math.max(limit, 50) : limit;
const response = toObjectRecord(await client.request("thread/list", { limit: listLimit }));
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
const descriptors: PersistedAgentDescriptor[] = await Promise.all(
threads.slice(0, limit).map(async (thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
@@ -656,3 +656,87 @@ describe("OpenCode adapter startTurn error handling", () => {
}
});
});
describe("OpenCode persisted sessions", () => {
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const storageRoot = mkdtempSync(path.join(os.tmpdir(), "opencode-storage-"));
const cwd = path.join(storageRoot, "repo");
const otherCwd = path.join(storageRoot, "other");
writeOpenCodeJson(storageRoot, "session/project-1/ses_old.json", {
id: "ses_old",
directory: cwd,
title: "Old session",
time: { created: 1000, updated: 1000 },
});
writeOpenCodeJson(storageRoot, "session/project-1/ses_new.json", {
id: "ses_new",
directory: cwd,
title: "New session",
time: { created: 2000, updated: 3000 },
});
writeOpenCodeJson(storageRoot, "session/project-2/ses_other.json", {
id: "ses_other",
directory: otherCwd,
title: "Other cwd",
time: { created: 4000, updated: 4000 },
});
writeOpenCodeJson(storageRoot, "message/ses_new/msg_user.json", {
id: "msg_user",
sessionID: "ses_new",
role: "user",
time: { created: 2100 },
});
writeOpenCodeJson(storageRoot, "part/msg_user/prt_user.json", {
id: "prt_user",
sessionID: "ses_new",
messageID: "msg_user",
type: "text",
text: "hello world",
time: { start: 2100 },
});
writeOpenCodeJson(storageRoot, "message/ses_new/msg_assistant.json", {
id: "msg_assistant",
sessionID: "ses_new",
role: "assistant",
time: { created: 2200 },
});
writeOpenCodeJson(storageRoot, "part/msg_assistant/prt_assistant.json", {
id: "prt_assistant",
sessionID: "ses_new",
messageID: "msg_assistant",
type: "text",
text: "hello back",
time: { start: 2200 },
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, storageRoot);
const descriptors = await client.listPersistedAgents({ cwd, limit: 1 });
expect(descriptors).toHaveLength(1);
expect(descriptors[0]).toMatchObject({
provider: "opencode",
sessionId: "ses_new",
cwd,
title: "New session",
persistence: {
provider: "opencode",
sessionId: "ses_new",
nativeHandle: "ses_new",
},
});
expect(descriptors[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z");
expect(descriptors[0]?.timeline).toEqual([
{ type: "user_message", text: "hello world", messageId: "msg_user" },
{ type: "assistant_message", text: "hello back" },
]);
rmSync(storageRoot, { recursive: true, force: true });
});
});
function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unknown): void {
const filePath = path.join(storageRoot, relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value), "utf8");
}
@@ -1,5 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -73,6 +75,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
const DEFAULT_MODES: AgentMode[] = [
{
@@ -92,6 +95,51 @@ const DEFAULT_MODES: AgentMode[] = [
},
];
const OpenCodeStoredSessionSchema = z
.object({
id: z.string().min(1),
directory: z.string().min(1),
title: z.string().nullable().optional(),
time: z
.object({
created: z.number().optional(),
updated: z.number().optional(),
})
.optional(),
})
.passthrough();
const OpenCodeStoredMessageSchema = z
.object({
id: z.string().min(1),
sessionID: z.string().min(1),
role: z.string().optional(),
time: z
.object({
created: z.number().optional(),
completed: z.number().optional(),
})
.optional(),
})
.passthrough();
const OpenCodeStoredPartSchema = z
.object({
type: z.string().optional(),
text: z.string().optional(),
time: z
.object({
start: z.number().optional(),
end: z.number().optional(),
})
.optional(),
})
.passthrough();
type OpenCodeStoredSession = z.infer<typeof OpenCodeStoredSessionSchema>;
type OpenCodeStoredMessage = z.infer<typeof OpenCodeStoredMessageSchema>;
type OpenCodeStoredPart = z.infer<typeof OpenCodeStoredPartSchema>;
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
type OpenCodeMessageRole = "user" | "assistant";
@@ -706,6 +754,162 @@ function buildOpenCodePromptParts(
return output;
}
function resolveOpenCodeStorageRoot(): string {
const xdgDataHome = process.env.XDG_DATA_HOME;
const dataHome =
typeof xdgDataHome === "string" && xdgDataHome.trim().length > 0
? xdgDataHome
: path.join(homedir(), ".local", "share");
return path.join(dataHome, "opencode", "storage");
}
async function collectOpenCodePersistedAgentsFromStorage(
storageRoot: string,
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
const sessions = await readOpenCodeStoredSessions(path.join(storageRoot, "session"));
const limit = options?.limit ?? OPENCODE_STORAGE_SESSION_LIMIT;
const candidates = sessions
.filter((session) => !options?.cwd || session.directory === options.cwd)
.sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left))
.slice(0, limit);
return await Promise.all(
candidates.map((session) => buildOpenCodePersistedAgentDescriptor(storageRoot, session)),
);
}
async function readOpenCodeStoredSessions(sessionRoot: string): Promise<OpenCodeStoredSession[]> {
const files = await findJsonFiles(sessionRoot);
const sessions: OpenCodeStoredSession[] = [];
for (const file of files) {
const parsed = await readJsonFile(file, OpenCodeStoredSessionSchema);
if (parsed) {
sessions.push(parsed);
}
}
return sessions;
}
async function buildOpenCodePersistedAgentDescriptor(
storageRoot: string,
session: OpenCodeStoredSession,
): Promise<PersistedAgentDescriptor> {
const timeline = await readOpenCodeSessionTimeline(storageRoot, session.id);
return {
provider: "opencode",
sessionId: session.id,
cwd: session.directory,
title: normalizeOpenCodeSessionTitle(session.title),
lastActivityAt: new Date(getOpenCodeSessionTimestamp(session)),
persistence: {
provider: "opencode",
sessionId: session.id,
nativeHandle: session.id,
metadata: {
provider: "opencode",
cwd: session.directory,
title: normalizeOpenCodeSessionTitle(session.title),
},
},
timeline,
};
}
async function readOpenCodeSessionTimeline(
storageRoot: string,
sessionId: string,
): Promise<AgentTimelineItem[]> {
const messageRoot = path.join(storageRoot, "message", sessionId);
const messageFiles = await findJsonFiles(messageRoot);
const messages: OpenCodeStoredMessage[] = [];
for (const file of messageFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema);
if (parsed?.sessionID === sessionId) {
messages.push(parsed);
}
}
const timeline: AgentTimelineItem[] = [];
for (const message of messages.sort(
(left, right) => getOpenCodeMessageTimestamp(left) - getOpenCodeMessageTimestamp(right),
)) {
const text = await readOpenCodeMessageText(storageRoot, message.id);
if (!text) {
continue;
}
if (message.role === "user") {
timeline.push({ type: "user_message", text, messageId: message.id });
} else if (message.role === "assistant") {
timeline.push({ type: "assistant_message", text });
}
}
return timeline;
}
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed?.type === "text" && typeof parsed.text === "string") {
parts.push(parsed);
}
}
return parts
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n");
}
async function findJsonFiles(root: string): Promise<string[]> {
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return findJsonFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readJsonFile<T>(file: string, schema: z.ZodType<T>): Promise<T | null> {
try {
return schema.parse(JSON.parse(await readFile(file, "utf8")));
} catch {
return null;
}
}
function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null {
const normalized = title?.trim();
return normalized ? normalized : null;
}
function getOpenCodeSessionTimestamp(session: OpenCodeStoredSession): number {
return session.time?.updated ?? session.time?.created ?? 0;
}
function getOpenCodeMessageTimestamp(message: OpenCodeStoredMessage): number {
return message.time?.created ?? message.time?.completed ?? 0;
}
function getOpenCodePartTimestamp(part: OpenCodeStoredPart): number {
return part.time?.start ?? part.time?.end ?? 0;
}
export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup,
@@ -1010,10 +1214,12 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
}
@@ -1199,10 +1405,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
// TODO: Implement by listing sessions from OpenCode
return [];
return collectOpenCodePersistedAgentsFromStorage(this.storageRoot, options);
}
async isAvailable(): Promise<boolean> {
+177
View File
@@ -17,6 +17,7 @@ import type {
AgentTimelineItem,
ListModesOptions,
ListModelsOptions,
PersistedAgentDescriptor,
} from "./agent/agent-sdk-types.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { ProviderDefinition } from "./agent/provider-registry.js";
@@ -69,6 +70,7 @@ interface SessionHandlerInternals {
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
handleFetchRecentProviderSessions(params: unknown): Promise<unknown>;
handleImportAgentRequest(params: unknown): Promise<unknown>;
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
@@ -931,6 +933,181 @@ afterEach(() => {
});
describe("session agent import", () => {
test("filters already imported provider sessions by native handle", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/import-filter";
const importedDescriptor: PersistedAgentDescriptor = {
provider: "claude",
sessionId: "provider-session-imported",
cwd,
title: "Already imported",
lastActivityAt: new Date("2026-04-30T12:00:00.000Z"),
persistence: {
provider: "claude",
sessionId: "provider-session-imported",
nativeHandle: "provider-native-imported",
},
timeline: [{ type: "user_message", text: "already imported" }],
};
const visibleDescriptor: PersistedAgentDescriptor = {
provider: "claude",
sessionId: "provider-session-visible",
cwd,
title: "Still importable",
lastActivityAt: new Date("2026-04-30T11:00:00.000Z"),
persistence: {
provider: "claude",
sessionId: "provider-session-visible",
nativeHandle: "provider-native-visible",
},
timeline: [{ type: "user_message", text: "still importable" }],
};
const agentManager = {
listAgents: vi.fn(() => [
{
provider: "claude",
persistence: {
provider: "claude",
sessionId: "stored-session-id",
nativeHandle: "provider-native-imported",
},
} as ManagedAgent,
]),
listImportablePersistedAgents: vi
.fn()
.mockResolvedValue([importedDescriptor, visibleDescriptor]),
subscribe: vi.fn(() => () => {}),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleFetchRecentProviderSessions({
type: "fetch_recent_provider_sessions_request",
requestId: "recent-provider-sessions",
cwd,
providers: ["claude"],
limit: 15,
});
expect(agentManager.listImportablePersistedAgents).toHaveBeenCalledWith({
limit: 200,
providerFilter: new Set(["claude"]),
cwd,
});
expect(messages).toContainEqual(
expect.objectContaining({
type: "fetch_recent_provider_sessions_response",
payload: expect.objectContaining({
requestId: "recent-provider-sessions",
filteredAlreadyImportedCount: 1,
entries: [
expect.objectContaining({
providerId: "claude",
providerHandleId: "provider-native-visible",
title: "Still importable",
}),
],
}),
}),
);
});
test("imports by provider handle id and hydrates title metadata from the first user message", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/imported-agent";
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
{ type: "assistant_message", text: "I will inspect the provider listing." },
];
const snapshot = {
id: "00000000-0000-4000-8000-000000000633",
provider: "custom-codex",
cwd,
capabilities: TEST_CAPABILITIES,
config: { provider: "custom-codex", cwd },
createdAt: new Date("2026-04-30T00:00:00.000Z"),
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: {
provider: "custom-codex",
sessionId: "thread-imported",
nativeHandle: "provider-thread-imported",
metadata: { provider: "custom-codex", cwd },
},
historyPrimed: true,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
internal: false,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
} satisfies ManagedAgent;
const agentManager = {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
findPersistedAgent: vi.fn().mockResolvedValue({
provider: "custom-codex",
sessionId: "thread-imported",
cwd,
title: null,
firstPromptPreview: "Trace recent provider sessions",
lastPromptPreview: "Trace recent provider sessions",
lastActivityAt: new Date("2026-04-30T00:00:00.000Z"),
persistence: snapshot.persistence,
}),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue(timeline),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleImportAgentRequest({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "provider-thread-imported",
cwd,
requestId: "import-thread",
});
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
"custom-codex",
"provider-thread-imported",
);
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
expect(agentManager.setTitle).toHaveBeenCalledWith(
snapshot.id,
"Trace recent provider sessions",
);
expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({
agentManager,
agentId: snapshot.id,
cwd,
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
explicitTitle: null,
}),
);
});
test("sets a provisional title and schedules auto-title generation from the first hydrated user message", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/imported-agent";
+72 -7
View File
@@ -130,6 +130,11 @@ import type {
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
ImportSessionsRequestError,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
@@ -662,7 +667,7 @@ class VoiceFeatureUnavailableError extends Error {
interface BuildImportPersistenceHandleInput {
provider: AgentProvider;
sessionId: string;
providerHandleId: string;
cwd?: string;
}
@@ -672,8 +677,8 @@ function buildImportPersistenceHandle(
const cwd = input.cwd ?? process.cwd();
return {
provider: input.provider,
sessionId: input.sessionId,
nativeHandle: input.sessionId,
sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId,
metadata: {
provider: input.provider,
cwd,
@@ -1819,6 +1824,8 @@ export class Session {
return this.handleFetchAgents(msg);
case "fetch_agent_history_request":
return this.handleFetchAgentHistory(msg);
case "fetch_recent_provider_sessions_request":
return this.handleFetchRecentProviderSessions(msg);
case "fetch_agent_request":
return this.handleFetchAgent(msg.agentId, msg.requestId);
case "delete_agent_request":
@@ -3172,11 +3179,26 @@ export class Session {
private async handleImportAgentRequest(
msg: Extract<SessionInboundMessage, { type: "import_agent_request" }>,
): Promise<void> {
const { provider, sessionId, cwd, labels, requestId } = msg;
this.sessionLogger.info({ sessionId, provider }, `Importing agent ${sessionId} (${provider})`);
const normalized = normalizeImportAgentRequest(msg);
if ("error" in normalized) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId: msg.requestId,
error: normalized.error,
},
});
return;
}
const { provider, providerHandleId, cwd, labels, requestId } = normalized;
this.sessionLogger.info(
{ providerHandleId, provider },
`Importing agent ${providerHandleId} (${provider})`,
);
try {
const descriptor = await this.agentManager.findPersistedAgent(provider, sessionId);
const descriptor = await this.agentManager.findPersistedAgent(provider, providerHandleId);
if (!descriptor && provider === "opencode" && !cwd) {
throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
@@ -3185,7 +3207,7 @@ export class Session {
const handle = descriptor
? applyImportCwdOverride(descriptor.persistence, cwd)
: buildImportPersistenceHandle({ provider, sessionId, cwd });
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
await this.unarchiveAgentByHandle(handle);
@@ -6685,6 +6707,49 @@ export class Session {
}
}
private async handleFetchRecentProviderSessions(
request: Extract<SessionInboundMessage, { type: "fetch_recent_provider_sessions_request" }>,
): Promise<void> {
try {
const result = await listImportableProviderSessions({
request,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
providerRegistry: this.getProviderRegistry(),
});
this.emit({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: request.requestId,
entries: result.entries,
...(result.filteredAlreadyImportedCount > 0
? { filteredAlreadyImportedCount: result.filteredAlreadyImportedCount }
: {}),
},
});
} catch (error) {
const code =
error instanceof ImportSessionsRequestError
? error.code
: "fetch_recent_provider_sessions_failed";
const message =
error instanceof Error ? error.message : "Failed to fetch recent provider sessions";
this.sessionLogger.error(
{ err: error },
"Failed to handle fetch_recent_provider_sessions_request",
);
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
private async handleFetchWorkspacesRequest(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
): Promise<void> {
@@ -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();
@@ -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",