Import existing Claude/Codex/OpenCode sessions (#766)

* Restrict provider import fan-out to enabled, non-derived allowlist

Move provider eligibility from a session-level post-filter to the agent
manager's fan-out so disabled providers are never spawned (fixes the
Gemini-disabled hang) and derived providers (like a custom Z.AI extending
Claude) don't duplicate their base provider's sessions.

Add an IMPORTABLE_PROVIDERS allowlist (claude, codex, opencode); ACP-based
providers are excluded from import discovery entirely.

* Filter Codex persisted threads by cwd before hydrating turns

Codex's listPersistedAgents previously called thread/read includeTurns on
every thread returned by thread/list, even when the import sheet was
scoped to one cwd. With many recent threads outside the scope, that fan
of reads dominated the import-sheet open latency.

Add an optional cwd hint to ListPersistedAgentsOptions, thread it through
the agent manager, and have Codex pre-filter the cheap thread/list rows by
cwd before scheduling the per-thread hydration. Widen the thread/list
window when filtering since most rows will be from other cwds. Other
providers ignore the hint.

* Fan out import session fetch per enabled provider

Sheet now reads the providers snapshot already warmed by the workspace
screen, filters to enabled importable providers (claude/codex/opencode),
and issues one fetchRecentProviderSessions request per provider via
useQueries with limit 15. Results are merged, deduped by handle, and
sorted by last activity.

Falls back to a single unfiltered request when the daemon does not
expose a provider snapshot (older servers). Per-provider failures show
inline so a single broken provider does not hide the rest.

* Add provider filter badges to import sheet

Show "All" plus one badge per enabled importable provider above the
session list when more than one is available. Single-select narrows the
already-merged list — no refetch. Each row now shows the provider's
icon next to its label so multiple providers stay visually distinct.

Selection resets when the sheet closes.

* Distinguish already-imported empty state in import sheet

Track how many descriptors were dropped because their handle was
already imported, surface the count back to the client as an optional
`filteredAlreadyImportedCount`, and switch the sheet copy to
"All recent sessions are already imported." when the merged list is
empty solely due to that filter.

* Cover already-imported provider session filtering

* List persisted OpenCode sessions for import

* Revalidate import sessions when sheet reopens

* Add agent persistSession plumbing

* Drop redundant visibility-transition refetch and test boilerplate

The import sheet's queries are gated on `enabled: visible`, and react-query
already refetches stale data when `enabled` flips on, so the manual
`wasVisibleRef` + `invalidateQueries` effect was redundant. Collapse the
two `selectedProvider` reset effects into one.

Drop `expect(...).toBeTruthy()` after `findByText` / `getByText` — those
helpers throw on miss, so the assertion was noise.

Drop `| null` from `buildOpenCodePersistedAgentDescriptor`'s return type
(it never returns null) and the dead `Boolean(descriptor)` filter at the
caller.

* Add provider-opaque session import path

Introduces fetchRecentProviderSessions on the daemon client plus an
importAgent overload accepting providerId/providerHandleId, and a
projection helper that renders persisted descriptors as provider-opaque
recent-session payloads with prompt previews.

* Unslop the import-session feature

Centralizes the importable-providers list, normalizes import_agent_request
shape at one boundary, and removes the per-provider fan-out fallback in
the import sheet. Old daemons without features.providersSnapshot now show
"Update host" instead of being served a degraded path.

Replaces internal-helper unit tests on the OpenCode and Codex providers
with boundary tests through listPersistedAgents; makes storageRoot
injectable on the OpenCode provider so the boundary test can target a
temp directory.

Splits the CLAUDE.md back-compat rule into two contracts: protocol stays
backward-compatible (always); features may require new daemons (no
fallback paths). Tags the two new compat shims with COMPAT(name) and
target removal dates.

* Extract importable-session listing into its own module

Move listImportableProviderSessions out of session.ts and drop the
unused generic listPersistedAgents from AgentManager.
This commit is contained in:
Mohamed Boudra
2026-05-09 16:36:06 +08:00
committed by GitHub
parent 8be2692b8a
commit 5b884aee0c
24 changed files with 3373 additions and 45 deletions
@@ -1849,6 +1849,185 @@ test("fetches paginated agent history separately from active agents", async () =
});
});
test("fetches scoped recent provider sessions", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.fetchRecentProviderSessions({
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "fetch_recent_provider_sessions_request";
requestId: string;
cwd?: string;
providers?: string[];
since?: string;
limit?: number;
};
};
expect(request.message).toMatchObject({
type: "fetch_recent_provider_sessions_request",
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: request.message.requestId,
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Import me",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: request.message.requestId,
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Import me",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
});
});
test("imports an agent by provider handle id", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.importAgent({
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "import_agent_request";
requestId: string;
providerId?: string;
providerHandleId?: string;
sessionId?: string;
cwd?: string;
};
};
expect(request.message).toMatchObject({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(request.message).not.toHaveProperty("sessionId");
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_resumed",
requestId: request.message.requestId,
agentId: "agent-1",
timelineSize: 0,
agent: {
id: "agent-1",
provider: "custom-codex",
cwd: "/tmp/repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: {
provider: "custom-codex",
sessionId: "thread-1",
nativeHandle: "thread-1",
},
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
},
},
}),
);
await expect(promise).resolves.toMatchObject({
id: "agent-1",
provider: "custom-codex",
});
});
test("uses server-provided dictation finish timeout budget", async () => {
vi.useFakeTimers();
const logger = createMockLogger();
+58 -5
View File
@@ -123,13 +123,21 @@ const perfNow: () => number =
? () => performance.now()
: () => Date.now();
export interface ImportAgentInput {
provider: AgentProvider;
sessionId: string;
interface ImportAgentInputBase {
cwd?: string;
labels?: Record<string, string>;
}
export type ImportAgentInput =
| (ImportAgentInputBase & {
providerId: string;
providerHandleId: string;
})
| (ImportAgentInputBase & {
provider: AgentProvider;
sessionId: string;
});
function normalizePassword(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -438,6 +446,21 @@ export type FetchAgentHistoryOptions = Omit<FetchAgentHistoryRequest, "type" | "
};
export type FetchAgentHistoryEntry = FetchAgentHistoryPayload["entries"][number];
export type FetchAgentHistoryPageInfo = FetchAgentHistoryPayload["pageInfo"];
type FetchRecentProviderSessionsPayload = Extract<
SessionOutboundMessage,
{ type: "fetch_recent_provider_sessions_response" }
>["payload"];
type FetchRecentProviderSessionsRequest = Extract<
SessionInboundMessage,
{ type: "fetch_recent_provider_sessions_request" }
>;
export type FetchRecentProviderSessionsOptions = Omit<
FetchRecentProviderSessionsRequest,
"type" | "requestId"
> & {
requestId?: string;
};
export type FetchRecentProviderSessionEntry = FetchRecentProviderSessionsPayload["entries"][number];
type FetchWorkspacesPayload = Extract<
SessionOutboundMessage,
{ type: "fetch_workspaces_response" }
@@ -1553,6 +1576,35 @@ export class DaemonClient {
});
}
async fetchRecentProviderSessions(
options?: FetchRecentProviderSessionsOptions,
): Promise<FetchRecentProviderSessionsPayload> {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_request",
requestId: resolvedRequestId,
...(options?.cwd ? { cwd: options.cwd } : {}),
...(options?.providers ? { providers: options.providers } : {}),
...(options?.since ? { since: options.since } : {}),
...(options?.limit ? { limit: options.limit } : {}),
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_recent_provider_sessions_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
}
async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload> {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
@@ -1902,8 +1954,9 @@ export class DaemonClient {
const message = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId,
provider: input.provider,
sessionId: input.sessionId,
...("providerId" in input
? { providerId: input.providerId, providerHandleId: input.providerHandleId }
: { provider: input.provider, sessionId: input.sessionId }),
...(input.cwd ? { cwd: input.cwd } : {}),
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
});
@@ -1090,6 +1090,17 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
return new TestAgentSession(config);
}
async listModels() {
return [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
];
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
@@ -1156,6 +1167,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
},
});
expect(client.lastResumeOverrides).toMatchObject({
model: "gpt-5.4",
modeId: "auto",
systemPrompt: "new prompt",
mcpServers: {
@@ -5506,3 +5518,109 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli
expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull();
}, 10_000);
class RecordingPersistedAgentsClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
calls = 0;
constructor(public readonly provider: AgentProvider) {}
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
throw new Error(`unexpected createSession for ${this.provider}`);
}
async resumeSession(): Promise<AgentSession> {
throw new Error(`unexpected resumeSession for ${this.provider}`);
}
async listModels() {
return [];
}
async listPersistedAgents(): Promise<PersistedAgentDescriptor[]> {
this.calls += 1;
return [
{
provider: this.provider,
sessionId: `${this.provider}-session`,
cwd: "/tmp/recent",
title: null,
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
persistence: { provider: this.provider, sessionId: `${this.provider}-session` },
timeline: [],
},
];
}
}
test.each([
[
"disabled",
"claude",
"codex",
{
claude: { enabled: true, derivedFromProviderId: null },
codex: { enabled: false, derivedFromProviderId: null },
},
],
[
"derived",
"claude",
"zai",
{
claude: { enabled: true, derivedFromProviderId: null },
zai: { enabled: true, derivedFromProviderId: "claude" },
},
],
[
"outside importable allowlist",
"claude",
"gemini",
{
claude: { enabled: true, derivedFromProviderId: null },
gemini: { enabled: true, derivedFromProviderId: null },
},
],
])(
"listImportablePersistedAgents skips %s providers in fan-out",
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
const includedClient = new RecordingPersistedAgentsClient(includedProvider);
const skippedClient = new RecordingPersistedAgentsClient(skippedProvider);
const manager = new AgentManager({
clients: { [includedProvider]: includedClient, [skippedProvider]: skippedClient },
providerDefinitions,
logger,
});
const result = await manager.listImportablePersistedAgents();
expect(includedClient.calls).toBe(1);
expect(skippedClient.calls).toBe(0);
expect(result.map((d) => d.provider)).toEqual([includedProvider]);
},
);
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
const claudeClient = new RecordingPersistedAgentsClient("claude");
const codexClient = new RecordingPersistedAgentsClient("codex");
const manager = new AgentManager({
clients: { claude: claudeClient, codex: codexClient },
providerDefinitions: {
claude: { enabled: true, derivedFromProviderId: null },
codex: { enabled: true, derivedFromProviderId: null },
},
logger,
});
const result = await manager.listImportablePersistedAgents({
providerFilter: new Set(["claude"]),
});
expect(claudeClient.calls).toBe(1);
expect(codexClient.calls).toBe(0);
expect(result.map((d) => d.provider)).toEqual(["claude"]);
});
@@ -52,6 +52,7 @@ import {
} from "./agent-stream-coalescer.js";
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -129,8 +130,12 @@ export interface SubscribeOptions {
replayState?: boolean;
}
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
provider?: AgentProvider;
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
/**
* When set, only providers in this set are scanned, in addition to the
* built-in importable allowlist + enabled + non-derived rules.
*/
providerFilter?: Set<string>;
};
export type AgentAttentionCallback = (params: {
@@ -152,6 +157,7 @@ interface AgentManagerRescueTimeouts {
interface ProviderEnabledFlag {
enabled: boolean;
derivedFromProviderId?: string | null;
}
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
@@ -416,6 +422,7 @@ function buildExplicitTimelineSeedForRegister(
export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDerivedFromId = new Map<AgentProvider, string | null>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
@@ -471,6 +478,7 @@ export class AgentManager {
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
if (definition) {
this.providerEnabled.set(provider, definition.enabled);
this.providerDerivedFromId.set(provider, definition.derivedFromProviderId ?? null);
}
}
for (const [provider, client] of Object.entries(input.clients)) {
@@ -592,24 +600,21 @@ export class AgentManager {
.map((agent) => Object.assign({}, agent));
}
async listPersistedAgents(
options?: PersistedAgentQueryOptions,
async listImportablePersistedAgents(
options?: ImportablePersistedAgentQueryOptions,
): Promise<PersistedAgentDescriptor[]> {
if (options?.provider) {
const client = this.requireClient(options.provider);
if (!client.listPersistedAgents) {
return [];
}
return client.listPersistedAgents({ limit: options.limit });
}
const providerEntries = Array.from(this.clients.entries()).filter(
([, client]) => !!client.listPersistedAgents,
([provider, client]) =>
!!client.listPersistedAgents &&
this.isProviderImportable(provider, options?.providerFilter),
);
const descriptorLists = await Promise.all(
providerEntries.map(async ([provider, client]) => {
try {
return await client.listPersistedAgents!({ limit: options?.limit });
return await client.listPersistedAgents!({
limit: options?.limit,
cwd: options?.cwd,
});
} catch (error) {
this.logger.warn(
{ err: error, provider },
@@ -627,6 +632,25 @@ export class AgentManager {
.slice(0, limit);
}
private isProviderImportable(
provider: AgentProvider,
providerFilter: Set<string> | undefined,
): boolean {
if (!IMPORTABLE_PROVIDERS.includes(provider as (typeof IMPORTABLE_PROVIDERS)[number])) {
return false;
}
if (this.providerEnabled.get(provider) === false) {
return false;
}
if (this.providerDerivedFromId.get(provider) != null) {
return false;
}
if (providerFilter && !providerFilter.has(provider)) {
return false;
}
return true;
}
async findPersistedAgent(
provider: AgentProvider,
sessionId: string,
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js";
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
import {
toAgentPayload,
toRecentProviderSessionDescriptorPayload,
toStoredAgentRecord,
type ManagedAgent,
} from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentFeature,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
@@ -392,3 +398,70 @@ describe("toAgentPayload", () => {
expect(payload.features).toEqual(features);
});
});
describe("toRecentProviderSessionDescriptorPayload", () => {
it("projects persisted descriptors to provider-opaque public recent sessions", () => {
const descriptor: PersistedAgentDescriptor = {
provider: "codex-custom",
sessionId: "legacy-session-id",
cwd: "/tmp/project",
title: "Import me",
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
persistence: {
provider: "codex-custom",
sessionId: "legacy-session-id",
nativeHandle: "provider-native-handle",
},
timeline: [
{ type: "assistant_message", text: "Ready" },
{ type: "user_message", text: " First prompt\n\nwith spacing " },
{ type: "user_message", text: "Second prompt" },
],
};
const payload = toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: "Custom Codex",
});
expect(payload).toEqual({
providerId: "codex-custom",
providerLabel: "Custom Codex",
providerHandleId: "provider-native-handle",
cwd: "/tmp/project",
title: "Import me",
firstPromptPreview: "First prompt with spacing",
lastPromptPreview: "Second prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
expect(payload).not.toHaveProperty("providerKind");
expect(payload).not.toHaveProperty("sessionId");
expect(payload).not.toHaveProperty("nativeHandle");
});
it("falls back to persistence session id when no provider native handle exists", () => {
const descriptor: PersistedAgentDescriptor = {
provider: "claude-custom",
sessionId: "descriptor-session-id",
cwd: "/tmp/project",
title: null,
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
persistence: {
provider: "claude-custom",
sessionId: "persistence-session-id",
},
timeline: [],
};
expect(
toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: "Custom Claude",
}),
).toMatchObject({
providerId: "claude-custom",
providerLabel: "Custom Claude",
providerHandleId: "persistence-session-id",
firstPromptPreview: null,
lastPromptPreview: null,
});
});
});
@@ -1,4 +1,8 @@
import type { AgentListItemPayload, AgentSnapshotPayload } from "../messages.js";
import type {
AgentListItemPayload,
AgentSnapshotPayload,
RecentProviderSessionDescriptorPayload,
} from "../messages.js";
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
@@ -10,7 +14,9 @@ import type {
AgentProvider,
AgentSessionConfig,
AgentRuntimeInfo,
AgentTimelineItem,
AgentUsage,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js";
@@ -23,6 +29,12 @@ interface ProjectionOptions {
internal?: boolean;
}
interface RecentProviderSessionProjectionOptions {
providerLabel: string;
}
const PROMPT_PREVIEW_MAX_LENGTH = 160;
function normalizeThinkingOptionId(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
@@ -247,6 +259,24 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
};
}
export function toRecentProviderSessionDescriptorPayload(
descriptor: PersistedAgentDescriptor,
options: RecentProviderSessionProjectionOptions,
): RecentProviderSessionDescriptorPayload {
const promptPreviews = collectPromptPreviews(descriptor.timeline);
return {
providerId: descriptor.provider,
providerLabel: options.providerLabel,
providerHandleId: descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId,
cwd: descriptor.cwd,
title: descriptor.title,
firstPromptPreview: promptPreviews[0] ?? null,
lastPromptPreview: promptPreviews.at(-1) ?? null,
lastActivityAt: descriptor.lastActivityAt.toISOString(),
};
}
export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
const timestamps = [record.updatedAt, record.lastActivityAt]
.filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -264,6 +294,26 @@ export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): s
return timestamps[0].raw;
}
function collectPromptPreviews(timeline: readonly AgentTimelineItem[]): string[] {
return timeline.flatMap((item) => {
if (item.type !== "user_message") {
return [];
}
const preview = normalizePromptPreview(item.text);
return preview ? [preview] : [];
});
}
function normalizePromptPreview(text: string): string | null {
const normalized = text.trim().replace(/\s+/g, " ");
if (!normalized) {
return null;
}
return normalized.length > PROMPT_PREVIEW_MAX_LENGTH
? normalized.slice(0, PROMPT_PREVIEW_MAX_LENGTH)
: normalized;
}
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (Object.prototype.hasOwnProperty.call(config, "title")) {
@@ -427,6 +427,13 @@ export interface AgentSlashCommand {
export interface ListPersistedAgentsOptions {
limit?: number;
/**
* Optional cwd hint. Providers that can cheaply pre-filter persisted
* sessions by working directory should do so before doing expensive
* work like fetching turn timelines. Providers that can't filter
* cheaply may ignore this hint.
*/
cwd?: string;
}
export interface PersistedAgentDescriptor {
@@ -0,0 +1,226 @@
import { expect, test, vi } from "vitest";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
import type { PersistedAgentDescriptor } from "./agent-sdk-types.js";
import {
ImportSessionsRequestError,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./import-sessions.js";
function makeDescriptor(args: {
provider?: string;
sessionId: string;
nativeHandle?: string;
cwd?: string;
title?: string | null;
lastActivityAt: string;
firstPrompt?: string;
lastPrompt?: string;
}): PersistedAgentDescriptor {
const provider = args.provider ?? "codex";
const cwd = args.cwd ?? "/tmp/project";
return {
provider,
sessionId: args.sessionId,
cwd,
title: args.title ?? null,
lastActivityAt: new Date(args.lastActivityAt),
persistence: {
provider,
sessionId: args.sessionId,
...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
metadata: { provider, cwd },
},
timeline: [
...(args.firstPrompt ? [{ type: "user_message" as const, text: args.firstPrompt }] : []),
...(args.lastPrompt ? [{ type: "user_message" as const, text: args.lastPrompt }] : []),
],
};
}
function makeRequest(
overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {},
): FetchRecentProviderSessionsRequestMessage {
return {
type: "fetch_recent_provider_sessions_request",
requestId: "recent-provider-sessions",
...overrides,
};
}
test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => {
const cwd = "/tmp/project";
const descriptors = [
makeDescriptor({
sessionId: "outside-cwd",
nativeHandle: "outside-cwd-handle",
cwd: "/tmp/elsewhere",
title: "Outside cwd",
lastActivityAt: "2026-04-30T12:05:00.000Z",
}),
makeDescriptor({
sessionId: "stored-session",
nativeHandle: "stored-handle",
cwd,
title: "Already stored",
lastActivityAt: "2026-04-30T12:04:00.000Z",
}),
makeDescriptor({
sessionId: "older-session",
nativeHandle: "older-handle",
cwd,
title: "Older than since",
lastActivityAt: "2026-04-29T23:59:59.000Z",
}),
makeDescriptor({
sessionId: "newer-session",
nativeHandle: "newer-handle",
cwd,
title: "Newer import",
lastActivityAt: "2026-04-30T12:02:00.000Z",
firstPrompt: "newer first prompt",
lastPrompt: "newer last prompt",
}),
makeDescriptor({
sessionId: "second-session",
nativeHandle: "second-handle",
cwd,
title: "Second import",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "second prompt",
}),
makeDescriptor({
sessionId: "third-session",
nativeHandle: "third-handle",
cwd,
title: "Third import",
lastActivityAt: "2026-04-30T11:59:00.000Z",
firstPrompt: "third prompt",
}),
makeDescriptor({
sessionId: "live-session",
nativeHandle: "live-handle",
cwd,
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
}),
];
const listImportablePersistedAgents = vi.fn(async () => descriptors);
const agentManager = {
listAgents: () =>
[
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
},
},
] as ManagedAgent[],
listImportablePersistedAgents,
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
const agentStorage = {
list: async () => [
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "stored-session",
nativeHandle: "stored-handle",
},
} as StoredAgentRecord,
],
} satisfies Pick<AgentStorage, "list">;
const result = await listImportableProviderSessions({
request: makeRequest({
cwd,
providers: ["codex"],
since: "2026-04-30T00:00:00.000Z",
limit: 2,
}),
agentManager,
agentStorage,
providerRegistry: { codex: { label: "Codex" } },
});
expect(listImportablePersistedAgents).toHaveBeenCalledWith({
limit: 200,
providerFilter: new Set(["codex"]),
cwd,
});
expect(result).toEqual({
filteredAlreadyImportedCount: 2,
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "newer-handle",
cwd,
title: "Newer import",
firstPromptPreview: "newer first prompt",
lastPromptPreview: "newer last prompt",
lastActivityAt: "2026-04-30T12:02:00.000Z",
},
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "second-handle",
cwd,
title: "Second import",
firstPromptPreview: "second prompt",
lastPromptPreview: "second prompt",
lastActivityAt: "2026-04-30T12:00:00.000Z",
},
],
});
});
test("listImportableProviderSessions rejects invalid since values", async () => {
await expect(
listImportableProviderSessions({
request: makeRequest({ since: "not-a-date" }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => [],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
providerRegistry: {},
}),
).rejects.toMatchObject(
new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since"),
);
});
test("normalizeImportAgentRequest accepts new and legacy import handle shapes", () => {
expect(
normalizeImportAgentRequest({
type: "import_agent_request",
requestId: "new-shape",
providerId: "custom-codex",
providerHandleId: "thread-1",
}),
).toEqual({
requestId: "new-shape",
provider: "custom-codex",
providerHandleId: "thread-1",
});
expect(
normalizeImportAgentRequest({
type: "import_agent_request",
requestId: "legacy-shape",
provider: "codex",
sessionId: "thread-2",
}),
).toEqual({
requestId: "legacy-shape",
provider: "codex",
providerHandleId: "thread-2",
});
});
@@ -0,0 +1,160 @@
import type { z } from "zod";
import type { ProviderDefinition } from "./provider-registry.js";
import type { AgentManager } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentPersistenceHandle,
AgentProvider,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
import type {
FetchRecentProviderSessionsRequestMessage,
ImportAgentRequestMessageSchema,
RecentProviderSessionDescriptorPayload,
} from "../../shared/messages.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
export interface NormalizedImportAgentRequest {
provider: string;
providerHandleId: string;
cwd?: string;
labels?: Record<string, string>;
requestId: string;
}
export class ImportSessionsRequestError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "ImportSessionsRequestError";
}
}
export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
agentStorage: Pick<AgentStorage, "list">;
providerRegistry: Record<string, Pick<ProviderDefinition, "label"> | undefined>;
}
export interface ListImportableProviderSessionsResult {
entries: RecentProviderSessionDescriptorPayload[];
filteredAlreadyImportedCount: number;
}
// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape
// alongside the new {providerId, providerHandleId} shape. Old clients
// (< target daemon floor) send the legacy fields. Drop the fallbacks and the
// .optional() in messages.ts when the supported client floor is >= the daemon
// version that ships the new shape (target: 2026-11-08).
export function normalizeImportAgentRequest(
msg: ImportAgentRequestMessage,
): NormalizedImportAgentRequest | { error: string } {
const provider = msg.providerId ?? msg.provider;
const providerHandleId = msg.providerHandleId ?? msg.sessionId;
if (!provider || !providerHandleId) {
return { error: "Import requires providerId and providerHandleId" };
}
return {
provider,
providerHandleId,
cwd: msg.cwd,
labels: msg.labels,
requestId: msg.requestId,
};
}
export async function listImportableProviderSessions(
input: ListImportableProviderSessionsInput,
): Promise<ListImportableProviderSessionsResult> {
const { request, agentManager, agentStorage, providerRegistry } = input;
const limit = request.limit ?? 20;
const sinceTimestamp = parseRecentProviderSessionsSince(request.since);
const providerFilter = request.providers ? new Set(request.providers) : undefined;
const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage);
const descriptors = await agentManager.listImportablePersistedAgents({
limit: 200,
providerFilter,
cwd: request.cwd,
});
let filteredAlreadyImportedCount = 0;
const candidates: PersistedAgentDescriptor[] = [];
for (const descriptor of descriptors) {
if (request.cwd && descriptor.cwd !== request.cwd) {
continue;
}
if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) {
continue;
}
const providerHandleId =
descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId;
if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) {
filteredAlreadyImportedCount += 1;
continue;
}
candidates.push(descriptor);
}
const entries = candidates
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
.slice(0, limit)
.map((descriptor) =>
toRecentProviderSessionDescriptorPayload(descriptor, {
providerLabel: providerRegistry[descriptor.provider]?.label ?? descriptor.provider,
}),
);
return { entries, filteredAlreadyImportedCount };
}
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
if (!since) {
return null;
}
const timestamp = Date.parse(since);
if (Number.isNaN(timestamp)) {
throw new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since");
}
return timestamp;
}
async function collectImportedProviderSessionHandles(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,
): Promise<Set<string>> {
const handles = new Set<string>();
for (const agent of agentManager.listAgents()) {
collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence);
}
for (const record of await agentStorage.list()) {
collectProviderSessionHandleKeys(handles, record.provider, record.persistence);
}
return handles;
}
function toProviderSessionHandleKey(provider: string, providerHandleId: string): string {
return `${provider}\0${providerHandleId}`;
}
function collectProviderSessionHandleKeys(
target: Set<string>,
provider: AgentProvider | StoredAgentRecord["provider"] | string,
persistence: AgentPersistenceHandle | null | undefined,
): void {
if (!persistence) {
return;
}
target.add(toProviderSessionHandleKey(provider, persistence.sessionId));
if (persistence.nativeHandle) {
target.add(toProviderSessionHandleKey(provider, persistence.nativeHandle));
}
}
@@ -46,11 +46,19 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
export interface ProviderDefinition extends AgentProviderDefinition {
enabled: boolean;
/**
* The id of another *registered* provider this one extends (e.g. a Z.AI
* profile that extends "claude"). null for built-in providers and for
* generic ACP providers (which only extend the literal "acp" sentinel).
*/
derivedFromProviderId: string | null;
createClient: (logger: Logger) => AgentClient;
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
}
export { IMPORTABLE_PROVIDERS } from "../../shared/importable-providers.js";
export interface BuildProviderRegistryOptions {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
@@ -70,6 +78,7 @@ interface ResolvedProvider {
profileModels: ProviderProfileModel[];
additionalModels: ProviderProfileModel[];
enabled: boolean;
derivedFromProviderId: string | null;
createBaseClient: (logger: Logger) => AgentClient;
}
@@ -375,6 +384,7 @@ function createRegistryEntry(
return {
...resolved.definition,
enabled: resolved.enabled,
derivedFromProviderId: resolved.derivedFromProviderId,
createClient: (providerLogger: Logger) =>
createResolvedProviderClient(providerLogger, provider, resolved),
fetchModels: async (options: ListModelsOptions) =>
@@ -441,6 +451,7 @@ function buildResolvedBuiltinProviders(
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
enabled: override?.enabled !== false,
derivedFromProviderId: null,
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
@@ -487,6 +498,7 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: null,
createBaseClient: (logger) =>
new GenericACPAgentClient({
logger,
@@ -517,6 +529,7 @@ function addDerivedProviders(
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
derivedFromProviderId: override.extends,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
});
}
@@ -15,6 +15,7 @@ import type {
} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
CodexAppServerAgentClient,
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -1656,3 +1657,62 @@ describe("Codex app-server provider", () => {
);
});
});
describe("Codex persisted sessions", () => {
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const allThreads = [
{
id: "thread-a1",
cwd: "/workspace/project-a",
preview: "First A session",
createdAt: 1000,
updatedAt: 2000,
},
{
id: "thread-a2",
cwd: "/workspace/project-a",
preview: "Second A session",
createdAt: 1500,
updatedAt: 2500,
},
{
id: "thread-b1",
cwd: "/workspace/project-b",
preview: "B session",
createdAt: 3000,
updatedAt: 4000,
},
];
const fakeClient = {
request: async (method: string) => {
if (method === "thread/list") return { data: allThreads };
if (method === "thread/read") return { thread: { turns: [] } };
return {};
},
notify: () => {},
dispose: async () => {},
};
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
_createCodexClient: () => fakeClient,
});
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
};
const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" });
expect(descriptors.map((d) => d.sessionId).sort()).toEqual(["thread-a1", "thread-a2"]);
expect(descriptors.every((d) => d.cwd === "/workspace/project-a")).toBe(true);
});
});
@@ -170,8 +170,18 @@ const CODEX_MODES: AgentMode[] = [
const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown): Promise<unknown>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
) => CodexAppServerClientLike;
}
const MODE_PRESETS: Record<
@@ -703,6 +713,20 @@ const CodexModelListResponseSchema = z.object({
.optional(),
});
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
): Array<Record<string, unknown>> {
if (!cwd) {
return threads;
}
// thread/list rows carry an optional cwd. The descriptor builder later
// falls back to process.cwd() if the field is missing, so we only match
// here when the row genuinely carries a cwd string — otherwise threads
// with no cwd would falsely match the daemon's own cwd.
return threads.filter((thread) => typeof thread.cwd === "string" && thread.cwd === cwd);
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
@@ -1731,7 +1755,7 @@ async function loadCodexThreadHistoryTimeline(params: {
return timeline;
}
function readCodexThread(client: CodexAppServerClient, threadId: string): Promise<unknown> {
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
@@ -4769,6 +4793,13 @@ export class CodexAppServerAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
if (options?.persistSession === false) {
this.logger.debug(
"Codex app-server does not expose an ephemeral-session option; persistSession=false is currently a no-op",
);
// TODO: Honor persistSession=false if app-server adds support, or route
// utility generations through `codex exec --ephemeral` in a larger change.
}
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const goalsEnabled = await this.resolveGoalsEnabled();
const session = new CodexAppServerAgentSession(
@@ -4814,15 +4845,23 @@ export class CodexAppServerAgentClient implements AgentClient {
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
const client =
this.deps._createCodexClient?.(child, this.logger) ??
new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
const response = toObjectRecord(await client.request("thread/list", { limit }));
const threads = Array.isArray(response?.data) ? response.data : [];
// thread/list returns the cheap `cwd` field. When the caller supplied
// a cwd hint we filter here so the per-thread `thread/read includeTurns`
// hydration below only runs for matching threads. Fetch a wider window
// when filtering since most threads will be from other cwds.
const listLimit = options?.cwd ? Math.max(limit, 50) : limit;
const response = toObjectRecord(await client.request("thread/list", { limit: listLimit }));
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
const descriptors: PersistedAgentDescriptor[] = await Promise.all(
threads.slice(0, limit).map(async (thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
@@ -656,3 +656,87 @@ describe("OpenCode adapter startTurn error handling", () => {
}
});
});
describe("OpenCode persisted sessions", () => {
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const storageRoot = mkdtempSync(path.join(os.tmpdir(), "opencode-storage-"));
const cwd = path.join(storageRoot, "repo");
const otherCwd = path.join(storageRoot, "other");
writeOpenCodeJson(storageRoot, "session/project-1/ses_old.json", {
id: "ses_old",
directory: cwd,
title: "Old session",
time: { created: 1000, updated: 1000 },
});
writeOpenCodeJson(storageRoot, "session/project-1/ses_new.json", {
id: "ses_new",
directory: cwd,
title: "New session",
time: { created: 2000, updated: 3000 },
});
writeOpenCodeJson(storageRoot, "session/project-2/ses_other.json", {
id: "ses_other",
directory: otherCwd,
title: "Other cwd",
time: { created: 4000, updated: 4000 },
});
writeOpenCodeJson(storageRoot, "message/ses_new/msg_user.json", {
id: "msg_user",
sessionID: "ses_new",
role: "user",
time: { created: 2100 },
});
writeOpenCodeJson(storageRoot, "part/msg_user/prt_user.json", {
id: "prt_user",
sessionID: "ses_new",
messageID: "msg_user",
type: "text",
text: "hello world",
time: { start: 2100 },
});
writeOpenCodeJson(storageRoot, "message/ses_new/msg_assistant.json", {
id: "msg_assistant",
sessionID: "ses_new",
role: "assistant",
time: { created: 2200 },
});
writeOpenCodeJson(storageRoot, "part/msg_assistant/prt_assistant.json", {
id: "prt_assistant",
sessionID: "ses_new",
messageID: "msg_assistant",
type: "text",
text: "hello back",
time: { start: 2200 },
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, storageRoot);
const descriptors = await client.listPersistedAgents({ cwd, limit: 1 });
expect(descriptors).toHaveLength(1);
expect(descriptors[0]).toMatchObject({
provider: "opencode",
sessionId: "ses_new",
cwd,
title: "New session",
persistence: {
provider: "opencode",
sessionId: "ses_new",
nativeHandle: "ses_new",
},
});
expect(descriptors[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z");
expect(descriptors[0]?.timeline).toEqual([
{ type: "user_message", text: "hello world", messageId: "msg_user" },
{ type: "assistant_message", text: "hello back" },
]);
rmSync(storageRoot, { recursive: true, force: true });
});
});
function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unknown): void {
const filePath = path.join(storageRoot, relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(value), "utf8");
}
@@ -1,5 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -73,6 +75,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
const DEFAULT_MODES: AgentMode[] = [
{
@@ -92,6 +95,51 @@ const DEFAULT_MODES: AgentMode[] = [
},
];
const OpenCodeStoredSessionSchema = z
.object({
id: z.string().min(1),
directory: z.string().min(1),
title: z.string().nullable().optional(),
time: z
.object({
created: z.number().optional(),
updated: z.number().optional(),
})
.optional(),
})
.passthrough();
const OpenCodeStoredMessageSchema = z
.object({
id: z.string().min(1),
sessionID: z.string().min(1),
role: z.string().optional(),
time: z
.object({
created: z.number().optional(),
completed: z.number().optional(),
})
.optional(),
})
.passthrough();
const OpenCodeStoredPartSchema = z
.object({
type: z.string().optional(),
text: z.string().optional(),
time: z
.object({
start: z.number().optional(),
end: z.number().optional(),
})
.optional(),
})
.passthrough();
type OpenCodeStoredSession = z.infer<typeof OpenCodeStoredSessionSchema>;
type OpenCodeStoredMessage = z.infer<typeof OpenCodeStoredMessageSchema>;
type OpenCodeStoredPart = z.infer<typeof OpenCodeStoredPartSchema>;
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
type OpenCodeMessageRole = "user" | "assistant";
@@ -706,6 +754,162 @@ function buildOpenCodePromptParts(
return output;
}
function resolveOpenCodeStorageRoot(): string {
const xdgDataHome = process.env.XDG_DATA_HOME;
const dataHome =
typeof xdgDataHome === "string" && xdgDataHome.trim().length > 0
? xdgDataHome
: path.join(homedir(), ".local", "share");
return path.join(dataHome, "opencode", "storage");
}
async function collectOpenCodePersistedAgentsFromStorage(
storageRoot: string,
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
const sessions = await readOpenCodeStoredSessions(path.join(storageRoot, "session"));
const limit = options?.limit ?? OPENCODE_STORAGE_SESSION_LIMIT;
const candidates = sessions
.filter((session) => !options?.cwd || session.directory === options.cwd)
.sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left))
.slice(0, limit);
return await Promise.all(
candidates.map((session) => buildOpenCodePersistedAgentDescriptor(storageRoot, session)),
);
}
async function readOpenCodeStoredSessions(sessionRoot: string): Promise<OpenCodeStoredSession[]> {
const files = await findJsonFiles(sessionRoot);
const sessions: OpenCodeStoredSession[] = [];
for (const file of files) {
const parsed = await readJsonFile(file, OpenCodeStoredSessionSchema);
if (parsed) {
sessions.push(parsed);
}
}
return sessions;
}
async function buildOpenCodePersistedAgentDescriptor(
storageRoot: string,
session: OpenCodeStoredSession,
): Promise<PersistedAgentDescriptor> {
const timeline = await readOpenCodeSessionTimeline(storageRoot, session.id);
return {
provider: "opencode",
sessionId: session.id,
cwd: session.directory,
title: normalizeOpenCodeSessionTitle(session.title),
lastActivityAt: new Date(getOpenCodeSessionTimestamp(session)),
persistence: {
provider: "opencode",
sessionId: session.id,
nativeHandle: session.id,
metadata: {
provider: "opencode",
cwd: session.directory,
title: normalizeOpenCodeSessionTitle(session.title),
},
},
timeline,
};
}
async function readOpenCodeSessionTimeline(
storageRoot: string,
sessionId: string,
): Promise<AgentTimelineItem[]> {
const messageRoot = path.join(storageRoot, "message", sessionId);
const messageFiles = await findJsonFiles(messageRoot);
const messages: OpenCodeStoredMessage[] = [];
for (const file of messageFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema);
if (parsed?.sessionID === sessionId) {
messages.push(parsed);
}
}
const timeline: AgentTimelineItem[] = [];
for (const message of messages.sort(
(left, right) => getOpenCodeMessageTimestamp(left) - getOpenCodeMessageTimestamp(right),
)) {
const text = await readOpenCodeMessageText(storageRoot, message.id);
if (!text) {
continue;
}
if (message.role === "user") {
timeline.push({ type: "user_message", text, messageId: message.id });
} else if (message.role === "assistant") {
timeline.push({ type: "assistant_message", text });
}
}
return timeline;
}
async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise<string> {
const partRoot = path.join(storageRoot, "part", messageId);
const partFiles = await findJsonFiles(partRoot);
const parts: OpenCodeStoredPart[] = [];
for (const file of partFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
if (parsed?.type === "text" && typeof parsed.text === "string") {
parts.push(parsed);
}
}
return parts
.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n");
}
async function findJsonFiles(root: string): Promise<string[]> {
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return findJsonFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readJsonFile<T>(file: string, schema: z.ZodType<T>): Promise<T | null> {
try {
return schema.parse(JSON.parse(await readFile(file, "utf8")));
} catch {
return null;
}
}
function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null {
const normalized = title?.trim();
return normalized ? normalized : null;
}
function getOpenCodeSessionTimestamp(session: OpenCodeStoredSession): number {
return session.time?.updated ?? session.time?.created ?? 0;
}
function getOpenCodeMessageTimestamp(message: OpenCodeStoredMessage): number {
return message.time?.created ?? message.time?.completed ?? 0;
}
function getOpenCodePartTimestamp(part: OpenCodeStoredPart): number {
return part.time?.start ?? part.time?.end ?? 0;
}
export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup,
@@ -1010,10 +1214,12 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
}
@@ -1199,10 +1405,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
// TODO: Implement by listing sessions from OpenCode
return [];
return collectOpenCodePersistedAgentsFromStorage(this.storageRoot, options);
}
async isAvailable(): Promise<boolean> {
+177
View File
@@ -17,6 +17,7 @@ import type {
AgentTimelineItem,
ListModesOptions,
ListModelsOptions,
PersistedAgentDescriptor,
} from "./agent/agent-sdk-types.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { ProviderDefinition } from "./agent/provider-registry.js";
@@ -69,6 +70,7 @@ interface SessionHandlerInternals {
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
handleFetchRecentProviderSessions(params: unknown): Promise<unknown>;
handleImportAgentRequest(params: unknown): Promise<unknown>;
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
@@ -931,6 +933,181 @@ afterEach(() => {
});
describe("session agent import", () => {
test("filters already imported provider sessions by native handle", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/import-filter";
const importedDescriptor: PersistedAgentDescriptor = {
provider: "claude",
sessionId: "provider-session-imported",
cwd,
title: "Already imported",
lastActivityAt: new Date("2026-04-30T12:00:00.000Z"),
persistence: {
provider: "claude",
sessionId: "provider-session-imported",
nativeHandle: "provider-native-imported",
},
timeline: [{ type: "user_message", text: "already imported" }],
};
const visibleDescriptor: PersistedAgentDescriptor = {
provider: "claude",
sessionId: "provider-session-visible",
cwd,
title: "Still importable",
lastActivityAt: new Date("2026-04-30T11:00:00.000Z"),
persistence: {
provider: "claude",
sessionId: "provider-session-visible",
nativeHandle: "provider-native-visible",
},
timeline: [{ type: "user_message", text: "still importable" }],
};
const agentManager = {
listAgents: vi.fn(() => [
{
provider: "claude",
persistence: {
provider: "claude",
sessionId: "stored-session-id",
nativeHandle: "provider-native-imported",
},
} as ManagedAgent,
]),
listImportablePersistedAgents: vi
.fn()
.mockResolvedValue([importedDescriptor, visibleDescriptor]),
subscribe: vi.fn(() => () => {}),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleFetchRecentProviderSessions({
type: "fetch_recent_provider_sessions_request",
requestId: "recent-provider-sessions",
cwd,
providers: ["claude"],
limit: 15,
});
expect(agentManager.listImportablePersistedAgents).toHaveBeenCalledWith({
limit: 200,
providerFilter: new Set(["claude"]),
cwd,
});
expect(messages).toContainEqual(
expect.objectContaining({
type: "fetch_recent_provider_sessions_response",
payload: expect.objectContaining({
requestId: "recent-provider-sessions",
filteredAlreadyImportedCount: 1,
entries: [
expect.objectContaining({
providerId: "claude",
providerHandleId: "provider-native-visible",
title: "Still importable",
}),
],
}),
}),
);
});
test("imports by provider handle id and hydrates title metadata from the first user message", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/imported-agent";
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" },
{ type: "assistant_message", text: "I will inspect the provider listing." },
];
const snapshot = {
id: "00000000-0000-4000-8000-000000000633",
provider: "custom-codex",
cwd,
capabilities: TEST_CAPABILITIES,
config: { provider: "custom-codex", cwd },
createdAt: new Date("2026-04-30T00:00:00.000Z"),
updatedAt: new Date("2026-04-30T00:00:00.000Z"),
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: {
provider: "custom-codex",
sessionId: "thread-imported",
nativeHandle: "provider-thread-imported",
metadata: { provider: "custom-codex", cwd },
},
historyPrimed: true,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
internal: false,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
} satisfies ManagedAgent;
const agentManager = {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
findPersistedAgent: vi.fn().mockResolvedValue({
provider: "custom-codex",
sessionId: "thread-imported",
cwd,
title: null,
firstPromptPreview: "Trace recent provider sessions",
lastPromptPreview: "Trace recent provider sessions",
lastActivityAt: new Date("2026-04-30T00:00:00.000Z"),
persistence: snapshot.persistence,
}),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue(timeline),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
};
const agentStorage = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
};
const session = createSessionForTest({ messages });
Object.assign(session, { agentManager, agentStorage });
await asSessionInternals(session).handleImportAgentRequest({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "provider-thread-imported",
cwd,
requestId: "import-thread",
});
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
"custom-codex",
"provider-thread-imported",
);
expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id);
expect(agentManager.setTitle).toHaveBeenCalledWith(
snapshot.id,
"Trace recent provider sessions",
);
expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({
agentManager,
agentId: snapshot.id,
cwd,
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
explicitTitle: null,
}),
);
});
test("sets a provisional title and schedules auto-title generation from the first hydrated user message", async () => {
const messages: unknown[] = [];
const cwd = "/tmp/imported-agent";
+72 -7
View File
@@ -130,6 +130,11 @@ import type {
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
ImportSessionsRequestError,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
@@ -662,7 +667,7 @@ class VoiceFeatureUnavailableError extends Error {
interface BuildImportPersistenceHandleInput {
provider: AgentProvider;
sessionId: string;
providerHandleId: string;
cwd?: string;
}
@@ -672,8 +677,8 @@ function buildImportPersistenceHandle(
const cwd = input.cwd ?? process.cwd();
return {
provider: input.provider,
sessionId: input.sessionId,
nativeHandle: input.sessionId,
sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId,
metadata: {
provider: input.provider,
cwd,
@@ -1819,6 +1824,8 @@ export class Session {
return this.handleFetchAgents(msg);
case "fetch_agent_history_request":
return this.handleFetchAgentHistory(msg);
case "fetch_recent_provider_sessions_request":
return this.handleFetchRecentProviderSessions(msg);
case "fetch_agent_request":
return this.handleFetchAgent(msg.agentId, msg.requestId);
case "delete_agent_request":
@@ -3172,11 +3179,26 @@ export class Session {
private async handleImportAgentRequest(
msg: Extract<SessionInboundMessage, { type: "import_agent_request" }>,
): Promise<void> {
const { provider, sessionId, cwd, labels, requestId } = msg;
this.sessionLogger.info({ sessionId, provider }, `Importing agent ${sessionId} (${provider})`);
const normalized = normalizeImportAgentRequest(msg);
if ("error" in normalized) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId: msg.requestId,
error: normalized.error,
},
});
return;
}
const { provider, providerHandleId, cwd, labels, requestId } = normalized;
this.sessionLogger.info(
{ providerHandleId, provider },
`Importing agent ${providerHandleId} (${provider})`,
);
try {
const descriptor = await this.agentManager.findPersistedAgent(provider, sessionId);
const descriptor = await this.agentManager.findPersistedAgent(provider, providerHandleId);
if (!descriptor && provider === "opencode" && !cwd) {
throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
@@ -3185,7 +3207,7 @@ export class Session {
const handle = descriptor
? applyImportCwdOverride(descriptor.persistence, cwd)
: buildImportPersistenceHandle({ provider, sessionId, cwd });
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
await this.unarchiveAgentByHandle(handle);
@@ -6685,6 +6707,49 @@ export class Session {
}
}
private async handleFetchRecentProviderSessions(
request: Extract<SessionInboundMessage, { type: "fetch_recent_provider_sessions_request" }>,
): Promise<void> {
try {
const result = await listImportableProviderSessions({
request,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
providerRegistry: this.getProviderRegistry(),
});
this.emit({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: request.requestId,
entries: result.entries,
...(result.filteredAlreadyImportedCount > 0
? { filteredAlreadyImportedCount: result.filteredAlreadyImportedCount }
: {}),
},
});
} catch (error) {
const code =
error instanceof ImportSessionsRequestError
? error.code
: "fetch_recent_provider_sessions_failed";
const message =
error instanceof Error ? error.message : "Failed to fetch recent provider sessions";
this.sessionLogger.error(
{ err: error },
"Failed to handle fetch_recent_provider_sessions_request",
);
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
private async handleFetchWorkspacesRequest(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
): Promise<void> {
@@ -21,6 +21,7 @@ import type {
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
PersistedAgentDescriptor,
} from "./agent/agent-sdk-types.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
@@ -62,6 +63,10 @@ interface SessionTestAccess {
list(...args: unknown[]): Promise<unknown[]>;
get(agentId: string): Promise<unknown>;
};
agentManager: {
listAgents(): unknown[];
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
};
workspaceRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
@@ -228,6 +233,30 @@ function makeManagedAgent(input: {
};
}
function makePersistedProviderSession(input: {
provider: string;
sessionId: string;
nativeHandle?: string;
cwd: string;
title?: string | null;
lastActivityAt: string;
firstPrompt?: string;
}): PersistedAgentDescriptor {
return {
provider: input.provider,
sessionId: input.sessionId,
cwd: input.cwd,
title: input.title ?? null,
lastActivityAt: new Date(input.lastActivityAt),
persistence: {
provider: input.provider,
sessionId: input.sessionId,
...(input.nativeHandle ? { nativeHandle: input.nativeHandle } : {}),
},
timeline: input.firstPrompt ? [{ type: "user_message", text: input.firstPrompt }] : [],
};
}
function agentIdsFromEntries(entries: Array<Record<string, unknown>>) {
return entries.map((entry) => AgentIdEntrySchema.parse(entry).agent.id);
}
@@ -1843,6 +1872,238 @@ test("fetch_agent_history_request pages archived historical rows separately", as
expect(session.agentUpdatesSubscription).toBeNull();
});
test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => [
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
},
},
];
const persistedDescriptors: PersistedAgentDescriptor[] = [
makePersistedProviderSession({
provider: "codex",
sessionId: "outside-filter",
nativeHandle: "outside-filter-handle",
cwd: "/tmp/elsewhere",
title: "Outside filter",
lastActivityAt: "2026-04-30T12:05:00.000Z",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "stored-session",
nativeHandle: "stored-handle",
cwd: "/tmp/recent",
title: "Already stored",
lastActivityAt: "2026-04-30T12:04:00.000Z",
}),
makePersistedProviderSession({
provider: "claude",
sessionId: "wrong-provider",
cwd: "/tmp/recent",
title: "Wrong provider",
lastActivityAt: "2026-04-30T12:03:00.000Z",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "older-session",
nativeHandle: "older-handle",
cwd: "/tmp/recent",
title: "Older than since",
lastActivityAt: "2026-04-29T23:59:59.000Z",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "newer-session",
nativeHandle: "newer-handle",
cwd: "/tmp/recent",
title: "Newer import",
lastActivityAt: "2026-04-30T12:02:00.000Z",
firstPrompt: "newer prompt",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "second-session",
nativeHandle: "second-handle",
cwd: "/tmp/recent",
title: "Second import",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "second prompt",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "third-session",
nativeHandle: "third-handle",
cwd: "/tmp/recent",
title: "Third import",
lastActivityAt: "2026-04-30T11:59:00.000Z",
firstPrompt: "third prompt",
}),
makePersistedProviderSession({
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
}),
];
// The real AgentManager filters by providerFilter at the fan-out level
// (Phase 1). Mirror that here so the mock matches the contract.
session.agentManager.listImportablePersistedAgents = async (options?: unknown) => {
const providerFilter = (options as { providerFilter?: Set<string> } | undefined)
?.providerFilter;
if (!providerFilter) {
return persistedDescriptors;
}
return persistedDescriptors.filter((d) => providerFilter.has(d.provider));
};
session.agentStorage.list = async () => [
{
id: "stored-agent",
provider: "codex",
cwd: "/tmp/recent",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
title: "Stored",
labels: {},
lastStatus: "closed",
persistence: {
provider: "codex",
sessionId: "stored-session",
nativeHandle: "stored-handle",
},
},
];
await session.handleMessage({
type: "fetch_recent_provider_sessions_request",
requestId: "req-recent-provider-sessions",
cwd: "/tmp/recent",
providers: ["codex"],
since: "2026-04-30T00:00:00.000Z",
limit: 2,
});
expect(emitted).toEqual([
{
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "newer-handle",
cwd: "/tmp/recent",
title: "Newer import",
firstPromptPreview: "newer prompt",
lastPromptPreview: "newer prompt",
lastActivityAt: "2026-04-30T12:02:00.000Z",
},
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "second-handle",
cwd: "/tmp/recent",
title: "Second import",
firstPromptPreview: "second prompt",
lastPromptPreview: "second prompt",
lastActivityAt: "2026-04-30T12:00:00.000Z",
},
],
filteredAlreadyImportedCount: 2,
},
},
]);
});
test("fetch_recent_provider_sessions_request forwards providerFilter to agent manager", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
let capturedOptions: { providerFilter?: Set<string>; limit?: number } | undefined;
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => [];
session.agentStorage.list = async () => [];
session.agentManager.listImportablePersistedAgents = async (options?: unknown) => {
capturedOptions = options as { providerFilter?: Set<string>; limit?: number };
return [];
};
await session.handleMessage({
type: "fetch_recent_provider_sessions_request",
requestId: "req-provider-filter",
cwd: "/tmp/recent",
providers: ["claude"],
});
expect(capturedOptions?.providerFilter).toBeInstanceOf(Set);
expect(Array.from(capturedOptions?.providerFilter ?? [])).toEqual(["claude"]);
expect(emitted).toEqual([
{
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-provider-filter",
entries: [],
},
},
]);
});
test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCount when all candidates are already imported", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => [
{
provider: "codex",
persistence: {
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
},
},
];
session.agentStorage.list = async () => [];
session.agentManager.listImportablePersistedAgents = async () => [
makePersistedProviderSession({
provider: "codex",
sessionId: "live-session",
nativeHandle: "live-handle",
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
}),
];
await session.handleMessage({
type: "fetch_recent_provider_sessions_request",
requestId: "req-all-imported",
cwd: "/tmp/recent",
providers: ["codex"],
});
expect(emitted).toEqual([
{
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-all-imported",
entries: [],
filteredAlreadyImportedCount: 1,
},
},
]);
});
test("fetch_agent_request still resolves archived historical agents", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -0,0 +1,6 @@
/**
* Providers eligible for "import recent session" discovery. ACP-based
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const;
+45 -2
View File
@@ -661,6 +661,21 @@ export type AgentListItemPayload = z.infer<typeof AgentListItemPayloadSchema>;
export type AgentStreamEventPayload = z.infer<typeof AgentStreamEventPayloadSchema>;
export const RecentProviderSessionDescriptorPayloadSchema = z.object({
providerId: z.string(),
providerLabel: z.string(),
providerHandleId: z.string(),
cwd: z.string(),
title: z.string().nullable(),
firstPromptPreview: z.string().nullable(),
lastPromptPreview: z.string().nullable(),
lastActivityAt: z.string(),
});
export type RecentProviderSessionDescriptorPayload = z.infer<
typeof RecentProviderSessionDescriptorPayloadSchema
>;
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
@@ -905,6 +920,15 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({
.optional(),
});
export const FetchRecentProviderSessionsRequestMessageSchema = z.object({
type: z.literal("fetch_recent_provider_sessions_request"),
requestId: z.string(),
cwd: z.string().optional(),
providers: z.array(z.string()).optional(),
since: z.string().optional(),
limit: z.number().int().positive().max(200).optional(),
});
export const FetchAgentRequestMessageSchema = z.object({
type: z.literal("fetch_agent_request"),
requestId: z.string(),
@@ -1060,8 +1084,10 @@ export const ResumeAgentRequestMessageSchema = z.object({
export const ImportAgentRequestMessageSchema = z.object({
type: z.literal("import_agent_request"),
provider: AgentProviderSchema,
sessionId: z.string(),
provider: AgentProviderSchema.optional(),
providerId: z.string().optional(),
sessionId: z.string().optional(),
providerHandleId: z.string().optional(),
cwd: z.string().optional(),
labels: z.record(z.string()).optional(),
requestId: z.string(),
@@ -1680,6 +1706,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AudioPlayedMessageSchema,
FetchAgentsRequestMessageSchema,
FetchAgentHistoryRequestMessageSchema,
FetchRecentProviderSessionsRequestMessageSchema,
FetchWorkspacesRequestMessageSchema,
FetchAgentRequestMessageSchema,
DeleteAgentRequestMessageSchema,
@@ -2287,6 +2314,15 @@ export const FetchAgentHistoryResponseMessageSchema = z.object({
}),
});
export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
type: z.literal("fetch_recent_provider_sessions_response"),
payload: z.object({
requestId: z.string(),
entries: z.array(RecentProviderSessionDescriptorPayloadSchema),
filteredAlreadyImportedCount: z.number().int().nonnegative().optional(),
}),
});
export const FetchWorkspacesResponseMessageSchema = z.object({
type: z.literal("fetch_workspaces_response"),
payload: z.object({
@@ -3324,6 +3360,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
FetchAgentHistoryResponseMessageSchema,
FetchRecentProviderSessionsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
StartWorkspaceScriptResponseMessageSchema,
@@ -3454,6 +3491,9 @@ export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessa
export type FetchAgentHistoryResponseMessage = z.infer<
typeof FetchAgentHistoryResponseMessageSchema
>;
export type FetchRecentProviderSessionsResponseMessage = z.infer<
typeof FetchRecentProviderSessionsResponseMessageSchema
>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
@@ -3530,6 +3570,9 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
export type FetchAgentHistoryRequestMessage = z.infer<typeof FetchAgentHistoryRequestMessageSchema>;
export type FetchRecentProviderSessionsRequestMessage = z.infer<
typeof FetchRecentProviderSessionsRequestMessageSchema
>;
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
@@ -1,6 +1,7 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import {
RecentProviderSessionDescriptorPayloadSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema,
@@ -110,6 +111,125 @@ describe("workspace message schemas", () => {
expect(response.type).toBe("fetch_agent_history_response");
});
test("parses recent provider session descriptors without legacy handle fields", () => {
const parsed = RecentProviderSessionDescriptorPayloadSchema.parse({
providerId: "custom-codex",
providerLabel: "Custom Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
expect(parsed).toEqual({
providerId: "custom-codex",
providerLabel: "Custom Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
});
});
test("parses fetch_recent_provider_sessions request and response", () => {
const request = SessionInboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_request",
requestId: "req-recent-provider-sessions",
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
});
const response = SessionOutboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "my-claude",
providerLabel: "My Claude",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
},
});
expect(request.type).toBe("fetch_recent_provider_sessions_request");
expect(request.providers).toEqual(["my-claude"]);
expect(response.payload).toEqual({
requestId: "req-recent-provider-sessions",
entries: [
{
providerId: "my-claude",
providerLabel: "My Claude",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Resume this",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
});
});
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
const response = SessionOutboundMessageSchema.parse({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: "req-recent-provider-sessions",
entries: [],
filteredAlreadyImportedCount: 3,
},
});
if (response.type !== "fetch_recent_provider_sessions_response") {
throw new Error("expected fetch_recent_provider_sessions_response");
}
expect(response.payload.filteredAlreadyImportedCount).toBe(3);
});
test("parses new and legacy import agent requests", () => {
const newRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId: "req-import-new",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
const legacyRequest = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId: "req-import-legacy",
provider: "custom-codex",
sessionId: "thread-1",
cwd: "/tmp/repo",
});
expect(newRequest).toEqual({
type: "import_agent_request",
requestId: "req-import-new",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(legacyRequest).toEqual({
type: "import_agent_request",
requestId: "req-import-legacy",
provider: "custom-codex",
sessionId: "thread-1",
cwd: "/tmp/repo",
});
});
test("parses open_project_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "open_project_request",