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:
@@ -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