mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix OpenCode session imports (#1055)
* Fix OpenCode session imports * Update OpenCode session tests after import cleanup
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -656,13 +656,14 @@ export class AgentManager {
|
||||
async findPersistedAgent(
|
||||
provider: AgentProvider,
|
||||
sessionId: string,
|
||||
options?: Pick<ListPersistedAgentsOptions, "cwd">,
|
||||
): Promise<PersistedAgentDescriptor | null> {
|
||||
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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -146,7 +146,9 @@ export async function importProviderSession(
|
||||
input: ImportProviderSessionInput,
|
||||
): Promise<ImportProviderSessionResult> {
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
|
||||
@@ -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<typeof OpenCodeStoredSessionSchema>;
|
||||
type OpenCodeStoredMessage = z.infer<typeof OpenCodeStoredMessageSchema>;
|
||||
type OpenCodeStoredPart = z.infer<typeof OpenCodeStoredPartSchema>;
|
||||
|
||||
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<OpencodeClient, "experimental" | "session">,
|
||||
options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
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<OpenCodeStoredSession[]> {
|
||||
const files = await findJsonFiles(sessionRoot);
|
||||
const sessions: OpenCodeStoredSession[] = [];
|
||||
for (const file of files) {
|
||||
const parsed = await readJsonFile(file, OpenCodeStoredSessionSchema);
|
||||
if (parsed) {
|
||||
sessions.push(parsed);
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async function buildOpenCodePersistedAgentDescriptor(
|
||||
storageRoot: string,
|
||||
session: OpenCodeStoredSession,
|
||||
client: Pick<OpencodeClient, "session">,
|
||||
session: OpenCodePersistedSession,
|
||||
): 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 {
|
||||
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<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 {
|
||||
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<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 = {
|
||||
buildOpenCodePromptParts,
|
||||
buildOpenCodeModelContextWindowLookup,
|
||||
@@ -1015,17 +982,14 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
private readonly storageRoot: string;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
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<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): 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) {
|
||||
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<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> {
|
||||
@@ -2299,7 +2274,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
_storageRoot: string,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = 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<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
|
||||
|
||||
@@ -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<unknown> = createEventStream([idleEvent()]);
|
||||
experimentalSessionListResponse: OpenCodeResponse = { data: [] };
|
||||
permissionReplyResponse: OpenCodeResponse = {};
|
||||
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
|
||||
providerListImplementation: (() => Promise<OpenCodeResponse>) | 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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user