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