Fix OpenCode session imports (#1055)

* Fix OpenCode session imports

* Update OpenCode session tests after import cleanup
This commit is contained in:
Mohamed Boudra
2026-05-16 22:08:26 +08:00
committed by GitHub
parent 1c38ffe0a9
commit 1812b14898
12 changed files with 550 additions and 305 deletions

View File

@@ -1207,9 +1207,11 @@ test("findPersistedAgent returns matching descriptors by session id or native ha
class PersistedAgentsClient extends TestAgentClient { class PersistedAgentsClient extends TestAgentClient {
lastLimit: number | undefined; lastLimit: number | undefined;
lastCwd: string | undefined;
override async listPersistedAgents(options?: { limit?: number }) { override async listPersistedAgents(options?: { limit?: number; cwd?: string }) {
this.lastLimit = options?.limit; this.lastLimit = options?.limit;
this.lastCwd = options?.cwd;
return descriptors; return descriptors;
} }
} }
@@ -1226,7 +1228,11 @@ test("findPersistedAgent returns matching descriptors by session id or native ha
await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]); await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]);
await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]); await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]);
await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull(); await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull();
await expect(
manager.findPersistedAgent("codex", "session-direct", { cwd: "/tmp/project" }),
).resolves.toBe(descriptors[0]);
expect(client.lastLimit).toBe(200); expect(client.lastLimit).toBe(200);
expect(client.lastCwd).toBe("/tmp/project");
}); });
test("reloadAgentSession passes daemon launch env through the provider launch context", async () => { test("reloadAgentSession passes daemon launch env through the provider launch context", async () => {

View File

@@ -656,13 +656,14 @@ export class AgentManager {
async findPersistedAgent( async findPersistedAgent(
provider: AgentProvider, provider: AgentProvider,
sessionId: string, sessionId: string,
options?: Pick<ListPersistedAgentsOptions, "cwd">,
): Promise<PersistedAgentDescriptor | null> { ): Promise<PersistedAgentDescriptor | null> {
const client = this.requireClient(provider); const client = this.requireClient(provider);
if (!client.listPersistedAgents) { if (!client.listPersistedAgents) {
return null; return null;
} }
const descriptors = await client.listPersistedAgents({ limit: 200 }); const descriptors = await client.listPersistedAgents({ limit: 200, cwd: options?.cwd });
return ( return (
descriptors.find((descriptor) => { descriptors.find((descriptor) => {
return ( return (

View File

@@ -374,6 +374,7 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( expect(agentManager.findPersistedAgent).toHaveBeenCalledWith(
"custom-codex", "custom-codex",
"provider-thread-imported", "provider-thread-imported",
{ cwd },
); );
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
descriptor.persistence, descriptor.persistence,

View File

@@ -146,7 +146,9 @@ export async function importProviderSession(
input: ImportProviderSessionInput, input: ImportProviderSessionInput,
): Promise<ImportProviderSessionResult> { ): Promise<ImportProviderSessionResult> {
const { provider, providerHandleId, cwd, labels } = input.request; const { provider, providerHandleId, cwd, labels } = input.request;
const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId); const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId, {
cwd,
});
if (!descriptor && provider === "opencode" && !cwd) { if (!descriptor && provider === "opencode" && !cwd) {
throw new Error( throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents", "OpenCode sessions require --cwd when the session cannot be found in persisted agents",

View File

@@ -71,7 +71,7 @@ describe("OpenCode full-access mode", () => {
], ],
}); });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const modes = await client.listModes({ cwd: "/tmp/project", force: false }); const modes = await client.listModes({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]); expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
@@ -84,7 +84,7 @@ describe("OpenCode full-access mode", () => {
test("reports full-access but sends prompts through OpenCode build agent", async () => { test("reports full-access but sends prompts through OpenCode build agent", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient(); const { openCodeClient, runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -109,7 +109,7 @@ describe("OpenCode full-access mode", () => {
}); });
const receivedEvents: AgentStreamEvent[] = []; const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -137,7 +137,7 @@ describe("OpenCode full-access mode", () => {
}); });
const receivedEvents: AgentStreamEvent[] = []; const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",

View File

@@ -40,7 +40,7 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
}); });
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false }); const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
@@ -66,7 +66,7 @@ test("passes explicit refresh force through server acquisition", async () => {
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
await client.listModels({ cwd: "/tmp/opencode-models", force: true }); await client.listModels({ cwd: "/tmp/opencode-models", force: true });
@@ -98,7 +98,7 @@ test("includes models from api-source providers not in connected", async () => {
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const models = await client.listModels({ cwd: "/tmp/opencode-models", force: false }); const models = await client.listModels({ cwd: "/tmp/opencode-models", force: false });
expect(models).toMatchObject([ expect(models).toMatchObject([
@@ -130,7 +130,7 @@ test("throws when no providers are accessible (neither connected nor api-source)
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
await expect(client.listModels({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow( await expect(client.listModels({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
"OpenCode has no connected providers", "OpenCode has no connected providers",
@@ -157,7 +157,7 @@ test("does not throw when only api-source providers are present with no connecte
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
await expect( await expect(
client.listModels({ cwd: "/tmp/opencode-models", force: false }), client.listModels({ cwd: "/tmp/opencode-models", force: false }),

View File

@@ -27,7 +27,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
const openCodeClient = createOpenCodeClientWithConnectedProvider(); const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual( await expect(session.listCommands?.()).resolves.toEqual(
@@ -47,7 +47,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
const openCodeClient = createOpenCodeClientWithConnectedProvider(); const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({ await expect(session.run("/compact")).resolves.toMatchObject({
@@ -79,7 +79,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
})(); })();
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help"); const runPromise = session.run("/help");

View File

@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test, vi } from "vitest"; import { beforeAll, describe, expect, test, vi } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
@@ -12,6 +12,10 @@ import {
translateOpenCodeEvent, translateOpenCodeEvent,
} from "./opencode-agent.js"; } from "./opencode-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
import type { import type {
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
@@ -677,7 +681,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const turn = await collectTurnEvents(streamSession(session, "hello")); const turn = await collectTurnEvents(streamSession(session, "hello"));
@@ -769,7 +772,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const turn = await collectTurnEvents(streamSession(session, "hello")); const turn = await collectTurnEvents(streamSession(session, "hello"));
@@ -833,7 +835,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -873,7 +874,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
new Map(), new Map(),
undefined, undefined,
false, false,
@@ -901,7 +901,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
await session.close(); await session.close();
@@ -968,7 +967,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const history: AgentStreamEvent[] = []; const history: AgentStreamEvent[] = [];
@@ -981,7 +979,11 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
timestamp: "2026-05-14T12:41:15.873Z", timestamp: "2026-05-14T12:41:15.873Z",
item: { type: "user_message", text: "Reply with exactly: probe ok" }, item: {
type: "user_message",
text: "Reply with exactly: probe ok",
messageId: "msg_user",
},
}, },
{ {
type: "timeline", type: "timeline",
@@ -1030,7 +1032,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const history: AgentStreamEvent[] = []; const history: AgentStreamEvent[] = [];
@@ -1084,7 +1085,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -1129,7 +1129,6 @@ describe("OpenCode adapter startTurn error handling", () => {
fakeClient, fakeClient,
"ses_unit_test", "ses_unit_test",
createTestLogger(), createTestLogger(),
"/tmp/opencode-storage",
); );
await session.startTurn("first"); await session.startTurn("first");
@@ -1155,58 +1154,104 @@ describe("OpenCode adapter startTurn error handling", () => {
describe("OpenCode persisted sessions", () => { describe("OpenCode persisted sessions", () => {
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => { test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const storageRoot = mkdtempSync(path.join(os.tmpdir(), "opencode-storage-")); const runtime = new TestOpenCodeRuntime();
const cwd = path.join(storageRoot, "repo"); const openCodeClient = new TestOpenCodeClient();
const otherCwd = path.join(storageRoot, "other"); const cwd = "/workspace/repo";
const otherCwd = "/workspace/other";
writeOpenCodeJson(storageRoot, "session/project-1/ses_old.json", { openCodeClient.experimentalSessionListResponse = {
id: "ses_old", data: [
directory: cwd, {
title: "Old session", id: "ses_old",
time: { created: 1000, updated: 1000 }, directory: cwd,
}); title: "Old session",
writeOpenCodeJson(storageRoot, "session/project-1/ses_new.json", { time: { created: 1000, updated: 1000 },
id: "ses_new", },
directory: cwd, {
title: "New session", id: "ses_new",
time: { created: 2000, updated: 3000 }, directory: cwd,
}); title: "New session",
writeOpenCodeJson(storageRoot, "session/project-2/ses_other.json", { time: { created: 2000, updated: 3000 },
id: "ses_other", },
directory: otherCwd, {
title: "Other cwd", id: "ses_other",
time: { created: 4000, updated: 4000 }, directory: otherCwd,
}); title: "Other cwd",
writeOpenCodeJson(storageRoot, "message/ses_new/msg_user.json", { time: { created: 4000, updated: 4000 },
id: "msg_user", },
sessionID: "ses_new", ],
role: "user", };
time: { created: 2100 }, openCodeClient.sessionMessagesResponse = {
}); data: [
writeOpenCodeJson(storageRoot, "part/msg_user/prt_user.json", { {
id: "prt_user", info: {
sessionID: "ses_new", id: "msg_user",
messageID: "msg_user", sessionID: "ses_new",
type: "text", role: "user",
text: "hello world", time: { created: 2100 },
time: { start: 2100 }, agent: "build",
}); model: { providerID: "opencode", modelID: "big-pickle" },
writeOpenCodeJson(storageRoot, "message/ses_new/msg_assistant.json", { },
id: "msg_assistant", parts: [
sessionID: "ses_new", {
role: "assistant", id: "prt_user",
time: { created: 2200 }, sessionID: "ses_new",
}); messageID: "msg_user",
writeOpenCodeJson(storageRoot, "part/msg_assistant/prt_assistant.json", { type: "text",
id: "prt_assistant", text: "hello world",
sessionID: "ses_new", time: { start: 2100 },
messageID: "msg_assistant", },
type: "text", ],
text: "hello back", },
time: { start: 2200 }, {
}); info: {
id: "msg_assistant",
sessionID: "ses_new",
role: "assistant",
time: { created: 2200, completed: 2400 },
structured: { fallback: false },
agent: "build",
providerID: "opencode",
modelID: "big-pickle",
},
parts: [
{
id: "prt_reasoning",
sessionID: "ses_new",
messageID: "msg_assistant",
type: "reasoning",
text: "thinking clearly",
time: { start: 2200 },
},
{
id: "prt_tool",
sessionID: "ses_new",
messageID: "msg_assistant",
type: "tool",
tool: "bash",
callID: "call_shell",
state: {
status: "completed",
input: { command: "echo hello" },
output: "hello\n",
},
time: { start: 2250, end: 2300 },
},
{
id: "prt_assistant",
sessionID: "ses_new",
messageID: "msg_assistant",
type: "text",
text: "hello back",
time: { start: 2350 },
},
],
},
],
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, storageRoot); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const descriptors = await client.listPersistedAgents({ cwd, limit: 1 }); const descriptors = await client.listPersistedAgents({ cwd, limit: 1 });
expect(descriptors).toHaveLength(1); expect(descriptors).toHaveLength(1);
@@ -1219,24 +1264,33 @@ describe("OpenCode persisted sessions", () => {
provider: "opencode", provider: "opencode",
sessionId: "ses_new", sessionId: "ses_new",
nativeHandle: "ses_new", nativeHandle: "ses_new",
metadata: {
modeId: "build",
model: "opencode/big-pickle",
},
}, },
}); });
expect(descriptors[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z"); expect(descriptors[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z");
expect(descriptors[0]?.timeline).toEqual([ expect(descriptors[0]?.timeline).toEqual([
{ type: "user_message", text: "hello world", messageId: "msg_user" }, { type: "user_message", text: "hello world", messageId: "msg_user" },
{ type: "reasoning", text: "thinking clearly" },
expect.objectContaining({
type: "tool_call",
callId: "call_shell",
status: "completed",
}),
{ type: "assistant_message", text: "hello back" }, { type: "assistant_message", text: "hello back" },
]); ]);
expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]);
rmSync(storageRoot, { recursive: true, force: true }); expect(openCodeClient.calls.experimentalSessionList).toEqual([
{ directory: cwd, archived: true, roots: true, limit: 1 },
]);
expect(openCodeClient.calls.sessionMessages).toEqual([
{ sessionID: "ses_new", directory: cwd },
]);
}); });
}); });
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");
}
function createTestDeferred<T>(): { function createTestDeferred<T>(): {
promise: Promise<T>; promise: Promise<T>;
resolve: (value: T) => void; resolve: (value: T) => void;

View File

@@ -1,12 +1,13 @@
import { readdir, readFile } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import path from "node:path";
import { import {
type AssistantMessage as OpenCodeAssistantMessage, type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent, type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput, type FilePartInput as OpenCodeFilePartInput,
type GlobalSession as OpenCodeGlobalSession,
type Message as OpenCodeMessage,
type OpencodeClient, type OpencodeClient,
type Part as OpenCodePart, type Part as OpenCodePart,
type Session as OpenCodeSession,
type TextPartInput as OpenCodeTextPartInput, type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client"; } from "@opencode-ai/sdk/v2/client";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
@@ -75,7 +76,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200; const OPENCODE_PERSISTED_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [ const DEFAULT_MODES: AgentMode[] = [
@@ -96,53 +97,14 @@ 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 OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
type OpenCodeMessageRole = "user" | "assistant"; type OpenCodeMessageRole = "user" | "assistant";
type OpenCodePersistedSession = OpenCodeSession | OpenCodeGlobalSession;
interface OpenCodeSessionMessage {
info: OpenCodeMessage;
parts: OpenCodePart[];
}
type OpenCodeMcpConfig = type OpenCodeMcpConfig =
| { | {
@@ -730,48 +692,41 @@ function buildOpenCodePromptParts(
return output; return output;
} }
function resolveOpenCodeStorageRoot(): string { async function collectOpenCodePersistedAgentsFromSdk(
const xdgDataHome = process.env.XDG_DATA_HOME; client: Pick<OpencodeClient, "experimental" | "session">,
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, options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<PersistedAgentDescriptor[]> {
const sessions = await readOpenCodeStoredSessions(path.join(storageRoot, "session")); const limit = options?.limit ?? OPENCODE_PERSISTED_SESSION_LIMIT;
const limit = options?.limit ?? OPENCODE_STORAGE_SESSION_LIMIT; const response = await client.experimental.session.list({
...(options?.cwd ? { directory: options.cwd } : {}),
archived: true,
roots: true,
limit,
});
if (response.error) {
throw new Error(`Failed to list OpenCode sessions: ${JSON.stringify(response.error)}`);
}
const sessions = response.data ?? [];
const candidates = sessions const candidates = sessions
.filter((session) => !options?.cwd || session.directory === options.cwd) .filter((session) => !options?.cwd || session.directory === options.cwd)
.sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left)) .sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left))
.slice(0, limit); .slice(0, limit);
return await Promise.all( return await Promise.all(
candidates.map((session) => buildOpenCodePersistedAgentDescriptor(storageRoot, session)), candidates.map((session) => buildOpenCodePersistedAgentDescriptor(client, 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( async function buildOpenCodePersistedAgentDescriptor(
storageRoot: string, client: Pick<OpencodeClient, "session">,
session: OpenCodeStoredSession, session: OpenCodePersistedSession,
): Promise<PersistedAgentDescriptor> { ): Promise<PersistedAgentDescriptor> {
const timeline = await readOpenCodeSessionTimeline(storageRoot, session.id); const messages = await readOpenCodeSessionMessagesFromSdk(client, session);
const timeline = buildOpenCodeSessionTimeline(messages);
const modeId = resolveOpenCodePersistedSessionModeId(session, messages);
const model = resolveOpenCodePersistedSessionModel(session, messages);
return { return {
provider: "opencode", provider: "opencode",
sessionId: session.id, sessionId: session.id,
@@ -786,120 +741,23 @@ async function buildOpenCodePersistedAgentDescriptor(
provider: "opencode", provider: "opencode",
cwd: session.directory, cwd: session.directory,
title: normalizeOpenCodeSessionTitle(session.title), title: normalizeOpenCodeSessionTitle(session.title),
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
}, },
}, },
timeline, 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 parts = await readOpenCodeStoredParts(storageRoot, messageId);
return readOpenCodeTextFromParts(parts);
}
async function readOpenCodeStoredParts(
storageRoot: string,
messageId: string,
): Promise<OpenCodeStoredPart[]> {
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) {
parts.push(parsed);
}
}
return parts.sort(
(left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right),
);
}
function readOpenCodeTextFromParts(parts: OpenCodeStoredPart[]): string {
return parts
.filter((part) => part.type === "text" && typeof part.text === "string")
.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 { function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null {
const normalized = title?.trim(); const normalized = title?.trim();
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function getOpenCodeSessionTimestamp(session: OpenCodeStoredSession): number { function getOpenCodeSessionTimestamp(session: OpenCodePersistedSession): number {
return session.time?.updated ?? session.time?.created ?? 0; 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;
}
function resolveOpenCodeReplayTimestamp(params: { function resolveOpenCodeReplayTimestamp(params: {
message: { time?: { created?: number; completed?: number } | undefined }; message: { time?: { created?: number; completed?: number } | undefined };
part?: unknown; part?: unknown;
@@ -965,6 +823,115 @@ function buildOpenCodeReplayPartTimelineEvent(params: {
}); });
} }
async function readOpenCodeSessionMessagesFromSdk(
client: Pick<OpencodeClient, "session">,
session: OpenCodePersistedSession,
): Promise<OpenCodeSessionMessage[]> {
const response = await client.session.messages({
sessionID: session.id,
directory: session.directory,
});
if (response.error || !response.data) {
return [];
}
return response.data;
}
function buildOpenCodeSessionTimeline(
messages: ReadonlyArray<OpenCodeSessionMessage>,
): AgentTimelineItem[] {
return messages.flatMap((message) =>
buildOpenCodeReplayTimelineEvents(message).map((event) => event.item),
);
}
function resolveOpenCodePersistedSessionModeId(
session: OpenCodePersistedSession,
messages: ReadonlyArray<OpenCodeSessionMessage>,
): string | undefined {
const agent = session.agent ?? messages.map(readOpenCodeMessageAgent).find(Boolean);
return agent ? normalizeOpenCodeModeId(agent) : undefined;
}
function readOpenCodeMessageAgent(message: OpenCodeSessionMessage): string | undefined {
const agent = message.info.agent;
return typeof agent === "string" && agent.trim() ? agent : undefined;
}
function resolveOpenCodePersistedSessionModel(
session: OpenCodePersistedSession,
messages: ReadonlyArray<OpenCodeSessionMessage>,
): string | undefined {
if (session.model) {
return buildOpenCodeModelLookupKey(session.model.providerID, session.model.id);
}
const model = messages.map(readOpenCodeMessageModel).find(Boolean);
return model ? buildOpenCodeModelLookupKey(model.providerID, model.modelID) : undefined;
}
function readOpenCodeMessageModel(
message: OpenCodeSessionMessage,
): { providerID: string; modelID: string } | undefined {
const { info } = message;
if (info.role === "user") {
return info.model;
}
return {
providerID: info.providerID,
modelID: info.modelID,
};
}
function buildOpenCodeReplayTimelineEvents(
message: OpenCodeSessionMessage,
): Extract<AgentStreamEvent, { type: "timeline" }>[] {
const { info, parts } = message;
if (info.role === "user") {
const text = parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("");
return text
? [
buildOpenCodeReplayTimelineEvent({
item: { type: "user_message", text, messageId: info.id },
message: info,
}),
]
: [];
}
const events: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
let emittedAssistantText = false;
for (const part of parts) {
if (part.type === "text" && part.text) {
emittedAssistantText = true;
}
const event = buildOpenCodeReplayPartTimelineEvent({ part, message: info });
if (event) {
events.push(event);
}
}
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
events.push(
buildOpenCodeReplayTimelineEvent({
item: { type: "assistant_message", text },
message: info,
}),
);
}
}
return events;
}
export const __openCodeInternals = { export const __openCodeInternals = {
buildOpenCodePromptParts, buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup, buildOpenCodeModelContextWindowLookup,
@@ -1015,17 +982,14 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>(); private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
constructor( constructor(
logger: Logger, logger: Logger,
runtimeSettings?: ProviderRuntimeSettings, runtimeSettings?: ProviderRuntimeSettings,
storageRoot?: string,
deps: OpenCodeAgentClientDeps = {}, deps: OpenCodeAgentClientDeps = {},
) { ) {
this.logger = logger.child({ module: "agent", provider: "opencode" }); this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings; this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.runtime = this.runtime =
deps.runtime ?? deps.runtime ??
new ProductionOpenCodeRuntime( new ProductionOpenCodeRuntime(
@@ -1069,7 +1033,6 @@ export class OpenCodeAgentClient implements AgentClient {
client, client,
session.id, session.id,
this.logger, this.logger,
this.storageRoot,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
acquisition.release, acquisition.release,
options?.persistSession, options?.persistSession,
@@ -1086,15 +1049,17 @@ export class OpenCodeAgentClient implements AgentClient {
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string); const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const cwd = overrides?.cwd ?? metadata.cwd;
if (!cwd) { if (!cwd) {
throw new Error("OpenCode resume requires the original working directory"); throw new Error("OpenCode resume requires the original working directory");
} }
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
...metadata,
...overrides,
provider: "opencode", provider: "opencode",
cwd, cwd,
...overrides,
}; };
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false }); const acquisition = await this.runtime.acquireServer({ force: false });
@@ -1112,7 +1077,6 @@ export class OpenCodeAgentClient implements AgentClient {
client, client,
handle.sessionId, handle.sessionId,
this.logger, this.logger,
this.storageRoot,
new Map(this.modelContextWindows), new Map(this.modelContextWindows),
acquisition.release, acquisition.release,
undefined, undefined,
@@ -1228,7 +1192,18 @@ export class OpenCodeAgentClient implements AgentClient {
async listPersistedAgents( async listPersistedAgents(
options?: ListPersistedAgentsOptions, options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<PersistedAgentDescriptor[]> {
return collectOpenCodePersistedAgentsFromStorage(this.storageRoot, options); const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = this.runtime.createClient({
baseUrl: url,
directory: options?.cwd ?? "",
});
try {
return await collectOpenCodePersistedAgentsFromSdk(client, options);
} finally {
acquisition.release();
}
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
@@ -2299,7 +2274,6 @@ class OpenCodeAgentSession implements AgentSession {
client: OpencodeClient, client: OpencodeClient,
sessionId: string, sessionId: string,
logger: Logger, logger: Logger,
_storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(), modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void, releaseServer?: () => void,
persistSession = true, persistSession = true,
@@ -2901,40 +2875,9 @@ class OpenCodeAgentSession implements AgentSession {
return; return;
} }
for (const { info, parts } of response.data) { for (const message of response.data) {
if (info.role === "user") { for (const event of buildOpenCodeReplayTimelineEvents(message)) {
const text = parts yield event;
.filter((p): p is Extract<OpenCodePart, { type: "text" }> => p.type === "text")
.map((p) => p.text)
.join("");
if (text) {
yield buildOpenCodeReplayTimelineEvent({
item: { type: "user_message", text },
message: info,
});
}
} else {
let emittedAssistantText = false;
for (const part of parts) {
if (part.type === "text" && part.text) {
emittedAssistantText = true;
}
const event = buildOpenCodeReplayPartTimelineEvent({ part, message: info });
if (event) {
yield event;
}
}
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
yield buildOpenCodeReplayTimelineEvent({
item: { type: "assistant_message", text },
message: info,
});
}
}
} }
} }
} }
@@ -3052,6 +2995,8 @@ class OpenCodeAgentSession implements AgentSession {
nativeHandle: this.sessionId, nativeHandle: this.sessionId,
metadata: { metadata: {
cwd: this.config.cwd, cwd: this.config.cwd,
...(this.config.modeId ? { modeId: this.config.modeId } : {}),
...(this.config.model ? { model: this.config.model } : {}),
}, },
}; };
} }

View File

@@ -113,7 +113,7 @@ test("does not fail an active OpenCode provider retry before the advertised retr
OPENCODE_DISABLE_AUTO_UPDATE: "1", OPENCODE_DISABLE_AUTO_UPDATE: "1",
}, },
}; };
const client = new OpenCodeAgentClient(logger, runtimeSettings, path.join(root, "paseo-storage")); const client = new OpenCodeAgentClient(logger, runtimeSettings);
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
const eventLog: Array<{ elapsedMs: number; event: AgentStreamEvent }> = []; const eventLog: Array<{ elapsedMs: number; event: AgentStreamEvent }> = [];
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined; let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;

View File

@@ -47,6 +47,7 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[], appAgents: [] as unknown[],
commandList: [] as unknown[], commandList: [] as unknown[],
eventSubscribe: [] as unknown[], eventSubscribe: [] as unknown[],
experimentalSessionList: [] as unknown[],
globalEvent: [] as unknown[], globalEvent: [] as unknown[],
permissionReply: [] as unknown[], permissionReply: [] as unknown[],
providerList: [] as unknown[], providerList: [] as unknown[],
@@ -65,6 +66,7 @@ export class TestOpenCodeClient {
appAgentsResponse: OpenCodeResponse = { data: [] }; appAgentsResponse: OpenCodeResponse = { data: [] };
commandListResponse: OpenCodeResponse = { data: [] }; commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]); eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]);
experimentalSessionListResponse: OpenCodeResponse = { data: [] };
permissionReplyResponse: OpenCodeResponse = {}; permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } }; providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null; providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
@@ -100,6 +102,14 @@ export class TestOpenCodeClient {
return { stream: this.eventStream }; return { stream: this.eventStream };
}, },
}, },
experimental: {
session: {
list: async (parameters: unknown) => {
this.calls.experimentalSessionList.push(parameters);
return this.experimentalSessionListResponse;
},
},
},
global: { global: {
event: async (options: unknown) => { event: async (options: unknown) => {
this.calls.globalEvent.push(options); this.calls.globalEvent.push(options);

View File

@@ -0,0 +1,226 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentPersistenceHandle, AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
import type { FetchRecentProviderSessionEntry } from "../../client/daemon-client.js";
const OPENCODE_REAL_TEST_MODEL = "opencode/big-pickle";
const OPENCODE_REAL_TEST_TIMEOUT_MS = 180_000;
function tmpCwd(): string {
const dir = mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-import-persistence-"));
return realpathSync(dir);
}
async function withConnectedOpenCodeDaemon(
run: (context: { client: DaemonClient }) => Promise<void>,
): Promise<void> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `opencode-import-persistence-${randomUUID()}` },
});
await run({ client });
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
}
}
function getProviderHandleId(handle: AgentPersistenceHandle): string {
return String(handle.nativeHandle ?? handle.sessionId);
}
function hasOpenCodeBigPickleModel(models: ReadonlyArray<{ id: string }>): boolean {
return models.some((model) => model.id === OPENCODE_REAL_TEST_MODEL);
}
function findProviderSessionEntry(
entries: ReadonlyArray<FetchRecentProviderSessionEntry>,
providerHandleId: string,
): FetchRecentProviderSessionEntry | undefined {
return entries.find((entry) => entry.providerHandleId === providerHandleId);
}
async function fetchCanonicalTimeline(
client: DaemonClient,
agentId: string,
): Promise<AgentTimelineItem[]> {
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
return timeline.entries.map((entry) => entry.item);
}
function digestTimeline(items: ReadonlyArray<AgentTimelineItem>): unknown {
return {
userText: collectTimelineText(items, "user_message"),
reasoningText: collectTimelineText(items, "reasoning"),
assistantText: collectTimelineText(items, "assistant_message"),
completedToolCalls: items.flatMap((item) => {
if (item.type !== "tool_call" || item.status !== "completed") {
return [];
}
return [
{
callId: item.callId,
toolName: item.toolName,
detail: item.detail,
},
];
}),
};
}
function collectTimelineText(
items: ReadonlyArray<AgentTimelineItem>,
type: "user_message" | "assistant_message" | "reasoning",
): string {
return items
.filter((item): item is Extract<AgentTimelineItem, { type: typeof type }> => item.type === type)
.map((item) => item.text)
.join("");
}
function collectCompletedShellOutputs(items: ReadonlyArray<AgentTimelineItem>): string[] {
return items.flatMap((item) => {
if (item.type !== "tool_call" || item.status !== "completed" || item.detail.type !== "shell") {
return [];
}
return [item.detail.output ?? ""];
});
}
describe("daemon E2E (real opencode) - persisted import resume", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test(
"imports and resumes a real opencode/big-pickle session from provider persistence",
async () => {
const cwd = tmpCwd();
const rememberedToken = `OPENCODE_SQLITE_IMPORT_TOKEN_${randomUUID().slice(0, 8)}`;
let providerHandleId = "";
let sourceTimelineDigest: unknown = null;
try {
await withConnectedOpenCodeDaemon(async ({ client }) => {
const models = await client.listProviderModels("opencode");
expect(hasOpenCodeBigPickleModel(models.models)).toBe(true);
const agent = await client.createAgent({
cwd,
title: "opencode import persistence source",
provider: "opencode",
model: OPENCODE_REAL_TEST_MODEL,
modeId: "full-access",
initialPrompt: `Use the bash tool to run exactly: printf '${rememberedToken}'. Then remember that token and reply exactly READY_TO_RESUME ${rememberedToken}.`,
});
const finish = await client.waitForFinish(agent.id, OPENCODE_REAL_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.lastMessage).toContain("READY_TO_RESUME");
expect(finish.final?.persistence).toMatchObject({
provider: "opencode",
sessionId: expect.stringMatching(/^ses_/),
nativeHandle: expect.stringMatching(/^ses_/),
metadata: { cwd },
});
providerHandleId = getProviderHandleId(finish.final!.persistence!);
const sourceTimeline = await fetchCanonicalTimeline(client, agent.id);
sourceTimelineDigest = digestTimeline(sourceTimeline);
expect(collectTimelineText(sourceTimeline, "user_message")).toContain(rememberedToken);
expect(collectCompletedShellOutputs(sourceTimeline)).toEqual([
expect.stringContaining(rememberedToken),
]);
expect(collectTimelineText(sourceTimeline, "assistant_message")).toContain(
`READY_TO_RESUME ${rememberedToken}`,
);
await client.deleteAgent(agent.id);
});
await withConnectedOpenCodeDaemon(async ({ client }) => {
const recent = await client.fetchRecentProviderSessions({
cwd,
providers: ["opencode"],
limit: 5,
});
const persistedEntry = findProviderSessionEntry(recent.entries, providerHandleId);
expect(persistedEntry).toMatchObject({
providerId: "opencode",
providerHandleId,
cwd,
});
const imported = await client.importAgent({
providerId: "opencode",
providerHandleId,
cwd,
});
expect(imported).toMatchObject({
provider: "opencode",
cwd,
model: OPENCODE_REAL_TEST_MODEL,
status: "idle",
runtimeInfo: {
model: OPENCODE_REAL_TEST_MODEL,
},
persistence: {
provider: "opencode",
sessionId: providerHandleId,
},
});
const importedTimeline = await fetchCanonicalTimeline(client, imported.id);
expect(digestTimeline(importedTimeline)).toEqual(sourceTimelineDigest);
await client.sendMessage(
imported.id,
"What token did I ask you to remember? Reply with only the token.",
);
const resumedFinish = await client.waitForFinish(
imported.id,
OPENCODE_REAL_TEST_TIMEOUT_MS,
);
expect(resumedFinish).toMatchObject({
status: "idle",
lastMessage: expect.stringContaining(rememberedToken),
});
await client.deleteAgent(imported.id);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
OPENCODE_REAL_TEST_TIMEOUT_MS * 2,
);
});