From 1812b1489897dc303b68c41419dd2c01d6b64223 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 16 May 2026 22:08:26 +0800 Subject: [PATCH] Fix OpenCode session imports (#1055) * Fix OpenCode session imports * Update OpenCode session tests after import cleanup --- .../src/server/agent/agent-manager.test.ts | 8 +- .../server/src/server/agent/agent-manager.ts | 3 +- .../src/server/agent/import-sessions.test.ts | 1 + .../src/server/agent/import-sessions.ts | 4 +- .../opencode-agent.full-access.test.ts | 8 +- ...opencode-agent.list-models-timeout.test.ts | 10 +- ...encode-agent.slash-command-timeout.test.ts | 6 +- .../agent/providers/opencode-agent.test.ts | 192 +++++---- .../server/agent/providers/opencode-agent.ts | 385 ++++++++---------- .../opencode-retry-timeout.real.e2e.test.ts | 2 +- .../test-utils/test-opencode-runtime.ts | 10 + ...encode-import-persistence.real.e2e.test.ts | 226 ++++++++++ 12 files changed, 550 insertions(+), 305 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 412615fc0..c4e28641d 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1207,9 +1207,11 @@ test("findPersistedAgent returns matching descriptors by session id or native ha class PersistedAgentsClient extends TestAgentClient { 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.lastCwd = options?.cwd; 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", "native-match")).resolves.toBe(descriptors[1]); 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.lastCwd).toBe("/tmp/project"); }); test("reloadAgentSession passes daemon launch env through the provider launch context", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a8bf45bff..c4312d8b8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -656,13 +656,14 @@ export class AgentManager { async findPersistedAgent( provider: AgentProvider, sessionId: string, + options?: Pick, ): Promise { const client = this.requireClient(provider); if (!client.listPersistedAgents) { return null; } - const descriptors = await client.listPersistedAgents({ limit: 200 }); + const descriptors = await client.listPersistedAgents({ limit: 200, cwd: options?.cwd }); return ( descriptors.find((descriptor) => { return ( diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 20db2d9a4..f46f26f95 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -374,6 +374,7 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( "custom-codex", "provider-thread-imported", + { cwd }, ); expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( descriptor.persistence, diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index fabb55cf3..fac29eb9f 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -146,7 +146,9 @@ export async function importProviderSession( input: ImportProviderSessionInput, ): Promise { 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) { throw new Error( "OpenCode sessions require --cwd when the session cannot be found in persisted agents", diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 26a0df5b4..3875eb0a8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -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 }); 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 () => { const { openCodeClient, runtime } = mockOpenCodeClient(); - const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", @@ -109,7 +109,7 @@ describe("OpenCode full-access mode", () => { }); const receivedEvents: AgentStreamEvent[] = []; - const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", @@ -137,7 +137,7 @@ describe("OpenCode full-access mode", () => { }); const receivedEvents: AgentStreamEvent[] = []; - const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime }); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project", diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index c18a5808e..5f4dc215e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -40,7 +40,7 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se }); 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 }); await vi.advanceTimersByTimeAsync(15_000); @@ -66,7 +66,7 @@ test("passes explicit refresh force through server acquisition", async () => { }; 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 }); @@ -98,7 +98,7 @@ test("includes models from api-source providers not in connected", async () => { }; 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 }); expect(models).toMatchObject([ @@ -130,7 +130,7 @@ test("throws when no providers are accessible (neither connected nor api-source) }; 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( "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); - 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 }), diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts index be93b53f2..384622c32 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -27,7 +27,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => { const openCodeClient = createOpenCodeClientWithConnectedProvider(); 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" }); await expect(session.listCommands?.()).resolves.toEqual( @@ -47,7 +47,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => { const openCodeClient = createOpenCodeClientWithConnectedProvider(); 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" }); await expect(session.run("/compact")).resolves.toMatchObject({ @@ -79,7 +79,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => { })(); 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 runPromise = session.run("/help"); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 3f717bc63..3e4e8b368 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1,5 +1,5 @@ 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 path from "node:path"; import { execFileSync } from "node:child_process"; @@ -12,6 +12,10 @@ import { translateOpenCodeEvent, } from "./opencode-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; +import { + TestOpenCodeClient, + TestOpenCodeRuntime, +} from "./opencode/test-utils/test-opencode-runtime.js"; import type { AgentSessionConfig, AgentStreamEvent, @@ -677,7 +681,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); const turn = await collectTurnEvents(streamSession(session, "hello")); @@ -769,7 +772,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); const turn = await collectTurnEvents(streamSession(session, "hello")); @@ -833,7 +835,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); const events: AgentStreamEvent[] = []; @@ -873,7 +874,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", new Map(), undefined, false, @@ -901,7 +901,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); await session.close(); @@ -968,7 +967,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); const history: AgentStreamEvent[] = []; @@ -981,7 +979,11 @@ describe("OpenCode adapter startTurn error handling", () => { type: "timeline", provider: "opencode", 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", @@ -1030,7 +1032,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); const history: AgentStreamEvent[] = []; @@ -1084,7 +1085,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); const events: AgentStreamEvent[] = []; @@ -1129,7 +1129,6 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), - "/tmp/opencode-storage", ); await session.startTurn("first"); @@ -1155,58 +1154,104 @@ 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"); + const runtime = new TestOpenCodeRuntime(); + const openCodeClient = new TestOpenCodeClient(); + const cwd = "/workspace/repo"; + const otherCwd = "/workspace/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 }, - }); + openCodeClient.experimentalSessionListResponse = { + data: [ + { + id: "ses_old", + directory: cwd, + title: "Old session", + time: { created: 1000, updated: 1000 }, + }, + { + id: "ses_new", + directory: cwd, + title: "New session", + time: { created: 2000, updated: 3000 }, + }, + { + id: "ses_other", + directory: otherCwd, + title: "Other cwd", + time: { created: 4000, updated: 4000 }, + }, + ], + }; + openCodeClient.sessionMessagesResponse = { + data: [ + { + info: { + id: "msg_user", + sessionID: "ses_new", + role: "user", + time: { created: 2100 }, + agent: "build", + model: { providerID: "opencode", modelID: "big-pickle" }, + }, + parts: [ + { + id: "prt_user", + sessionID: "ses_new", + messageID: "msg_user", + type: "text", + text: "hello world", + time: { start: 2100 }, + }, + ], + }, + { + 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 }); expect(descriptors).toHaveLength(1); @@ -1219,24 +1264,33 @@ describe("OpenCode persisted sessions", () => { provider: "opencode", sessionId: "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]?.timeline).toEqual([ { 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" }, ]); - - rmSync(storageRoot, { recursive: true, force: true }); + expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]); + 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(): { promise: Promise; resolve: (value: T) => void; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 1030856f5..356ba54f2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,12 +1,13 @@ -import { readdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import path from "node:path"; import { type AssistantMessage as OpenCodeAssistantMessage, type Event as OpenCodeEvent, type FilePartInput as OpenCodeFilePartInput, + type GlobalSession as OpenCodeGlobalSession, + type Message as OpenCodeMessage, type OpencodeClient, type Part as OpenCodePart, + type Session as OpenCodeSession, type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; @@ -75,7 +76,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 OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; 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; -type OpenCodeStoredMessage = z.infer; -type OpenCodeStoredPart = z.infer; - type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; type OpenCodeMessageRole = "user" | "assistant"; +type OpenCodePersistedSession = OpenCodeSession | OpenCodeGlobalSession; + +interface OpenCodeSessionMessage { + info: OpenCodeMessage; + parts: OpenCodePart[]; +} type OpenCodeMcpConfig = | { @@ -730,48 +692,41 @@ 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, +async function collectOpenCodePersistedAgentsFromSdk( + client: Pick, options?: ListPersistedAgentsOptions, ): Promise { - const sessions = await readOpenCodeStoredSessions(path.join(storageRoot, "session")); - const limit = options?.limit ?? OPENCODE_STORAGE_SESSION_LIMIT; + const limit = options?.limit ?? OPENCODE_PERSISTED_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 .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)), + candidates.map((session) => buildOpenCodePersistedAgentDescriptor(client, session)), ); } -async function readOpenCodeStoredSessions(sessionRoot: string): Promise { - 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, + client: Pick, + session: OpenCodePersistedSession, ): Promise { - 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 { provider: "opencode", sessionId: session.id, @@ -786,120 +741,23 @@ async function buildOpenCodePersistedAgentDescriptor( provider: "opencode", cwd: session.directory, title: normalizeOpenCodeSessionTitle(session.title), + ...(modeId ? { modeId } : {}), + ...(model ? { model } : {}), }, }, timeline, }; } -async function readOpenCodeSessionTimeline( - storageRoot: string, - sessionId: string, -): Promise { - 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 { - const parts = await readOpenCodeStoredParts(storageRoot, messageId); - return readOpenCodeTextFromParts(parts); -} - -async function readOpenCodeStoredParts( - storageRoot: string, - messageId: string, -): Promise { - 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 { - 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(file: string, schema: z.ZodType): Promise { - 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 { +function getOpenCodeSessionTimestamp(session: OpenCodePersistedSession): 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; -} - function resolveOpenCodeReplayTimestamp(params: { message: { time?: { created?: number; completed?: number } | undefined }; part?: unknown; @@ -965,6 +823,115 @@ function buildOpenCodeReplayPartTimelineEvent(params: { }); } +async function readOpenCodeSessionMessagesFromSdk( + client: Pick, + session: OpenCodePersistedSession, +): Promise { + 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, +): AgentTimelineItem[] { + return messages.flatMap((message) => + buildOpenCodeReplayTimelineEvents(message).map((event) => event.item), + ); +} + +function resolveOpenCodePersistedSessionModeId( + session: OpenCodePersistedSession, + messages: ReadonlyArray, +): 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, +): 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[] { + const { info, parts } = message; + if (info.role === "user") { + const text = parts + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join(""); + + return text + ? [ + buildOpenCodeReplayTimelineEvent({ + item: { type: "user_message", text, messageId: info.id }, + message: info, + }), + ] + : []; + } + + const events: Extract[] = []; + 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 = { buildOpenCodePromptParts, buildOpenCodeModelContextWindowLookup, @@ -1015,17 +982,14 @@ export class OpenCodeAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly modelContextWindows = new Map(); - private readonly storageRoot: string; constructor( logger: Logger, runtimeSettings?: ProviderRuntimeSettings, - storageRoot?: string, deps: OpenCodeAgentClientDeps = {}, ) { this.logger = logger.child({ module: "agent", provider: "opencode" }); this.runtimeSettings = runtimeSettings; - this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot(); this.runtime = deps.runtime ?? new ProductionOpenCodeRuntime( @@ -1069,7 +1033,6 @@ export class OpenCodeAgentClient implements AgentClient { client, session.id, this.logger, - this.storageRoot, new Map(this.modelContextWindows), acquisition.release, options?.persistSession, @@ -1086,15 +1049,17 @@ export class OpenCodeAgentClient implements AgentClient { overrides?: Partial, launchContext?: AgentLaunchContext, ): Promise { - const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string); + const metadata = (handle.metadata ?? {}) as Partial; + const cwd = overrides?.cwd ?? metadata.cwd; if (!cwd) { throw new Error("OpenCode resume requires the original working directory"); } const config: AgentSessionConfig = { + ...metadata, + ...overrides, provider: "opencode", cwd, - ...overrides, }; const openCodeConfig = this.assertConfig(config); const acquisition = await this.runtime.acquireServer({ force: false }); @@ -1112,7 +1077,6 @@ export class OpenCodeAgentClient implements AgentClient { client, handle.sessionId, this.logger, - this.storageRoot, new Map(this.modelContextWindows), acquisition.release, undefined, @@ -1228,7 +1192,18 @@ export class OpenCodeAgentClient implements AgentClient { async listPersistedAgents( options?: ListPersistedAgentsOptions, ): Promise { - 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 { @@ -2299,7 +2274,6 @@ class OpenCodeAgentSession implements AgentSession { client: OpencodeClient, sessionId: string, logger: Logger, - _storageRoot: string, modelContextWindowsByModelKey: ReadonlyMap = new Map(), releaseServer?: () => void, persistSession = true, @@ -2901,40 +2875,9 @@ class OpenCodeAgentSession implements AgentSession { return; } - for (const { info, parts } of response.data) { - if (info.role === "user") { - const text = parts - .filter((p): p is Extract => 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, - }); - } - } + for (const message of response.data) { + for (const event of buildOpenCodeReplayTimelineEvents(message)) { + yield event; } } } @@ -3052,6 +2995,8 @@ class OpenCodeAgentSession implements AgentSession { nativeHandle: this.sessionId, metadata: { cwd: this.config.cwd, + ...(this.config.modeId ? { modeId: this.config.modeId } : {}), + ...(this.config.model ? { model: this.config.model } : {}), }, }; } diff --git a/packages/server/src/server/agent/providers/opencode-retry-timeout.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-retry-timeout.real.e2e.test.ts index bf3bcc875..b7414e882 100644 --- a/packages/server/src/server/agent/providers/opencode-retry-timeout.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-retry-timeout.real.e2e.test.ts @@ -113,7 +113,7 @@ test("does not fail an active OpenCode provider retry before the advertised retr 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 eventLog: Array<{ elapsedMs: number; event: AgentStreamEvent }> = []; let session: Awaited> | undefined; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts index 5d695ebaa..f68757592 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts @@ -47,6 +47,7 @@ export class TestOpenCodeClient { appAgents: [] as unknown[], commandList: [] as unknown[], eventSubscribe: [] as unknown[], + experimentalSessionList: [] as unknown[], globalEvent: [] as unknown[], permissionReply: [] as unknown[], providerList: [] as unknown[], @@ -65,6 +66,7 @@ export class TestOpenCodeClient { appAgentsResponse: OpenCodeResponse = { data: [] }; commandListResponse: OpenCodeResponse = { data: [] }; eventStream: AsyncIterable = createEventStream([idleEvent()]); + experimentalSessionListResponse: OpenCodeResponse = { data: [] }; permissionReplyResponse: OpenCodeResponse = {}; providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } }; providerListImplementation: (() => Promise) | null = null; @@ -100,6 +102,14 @@ export class TestOpenCodeClient { return { stream: this.eventStream }; }, }, + experimental: { + session: { + list: async (parameters: unknown) => { + this.calls.experimentalSessionList.push(parameters); + return this.experimentalSessionListResponse; + }, + }, + }, global: { event: async (options: unknown) => { this.calls.globalEvent.push(options); diff --git a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts new file mode 100644 index 000000000..4500cb459 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts @@ -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, +): Promise { + 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, + providerHandleId: string, +): FetchRecentProviderSessionEntry | undefined { + return entries.find((entry) => entry.providerHandleId === providerHandleId); +} + +async function fetchCanonicalTimeline( + client: DaemonClient, + agentId: string, +): Promise { + const timeline = await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + return timeline.entries.map((entry) => entry.item); +} + +function digestTimeline(items: ReadonlyArray): 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, + type: "user_message" | "assistant_message" | "reasoning", +): string { + return items + .filter((item): item is Extract => item.type === type) + .map((item) => item.text) + .join(""); +} + +function collectCompletedShellOutputs(items: ReadonlyArray): 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, + ); +});