From 4970651b6bc08e2066412e4a889967bbda976542 Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Fri, 5 Dec 2025 19:42:55 -0800 Subject: [PATCH] feat(acp): add session persistence and history replay - ACPSessionManager now persists sessions to Storage - loadSession properly loads existing sessions from disk - Restores user's model choice when loading a session - replaySessionHistory sends session/update notifications for conversation history - Added tests for session persistence --- packages/opencode/src/acp/agent.ts | 89 +++++++++++++++- packages/opencode/src/acp/session.ts | 56 +++++++++++ packages/opencode/src/acp/types.ts | 1 + packages/opencode/test/acp/session.test.ts | 112 +++++++++++++++++++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/acp/session.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ff71b045304c..57e301225039 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -24,6 +24,7 @@ import type { ACPConfig, ACPSessionState } from "./types" import { Provider } from "../provider/provider" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" +import { Session } from "@/session" import { Config } from "@/config/config" import { MCP } from "@/mcp" import { Todo } from "@/session/todo" @@ -416,10 +417,18 @@ export namespace ACP { } async loadSession(params: LoadSessionRequest) { - const directory = params.cwd - const model = await defaultModel(this.config, directory) const sessionId = params.sessionId + const existingSession = await this.sessionManager.load(sessionId) + if (!existingSession) { + throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) + } + + const directory = existingSession.cwd + const model = existingSession.model ?? (await defaultModel(this.config, directory)) + + this.setupEventSubscriptions(existingSession) + const providers = await this.sdk.config .providers({ throwOnError: true, query: { directory } }) .then((x) => x.data.providers) @@ -527,6 +536,8 @@ export namespace ACP { }) }, 0) + await this.replaySessionHistory(sessionId) + return { sessionId, models: { @@ -541,6 +552,80 @@ export namespace ACP { } } + private async replaySessionHistory(sessionId: string) { + try { + const messages = await Session.messages({ sessionID: sessionId }) + + for (const msg of messages) { + if (msg.info.role === "user") { + for (const part of msg.parts) { + if (part.type === "text") { + this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: part.text }, + }, + }) + } + } + } else if (msg.info.role === "assistant") { + for (const part of msg.parts) { + if (part.type === "text") { + this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: part.text }, + }, + }) + } else if (part.type === "reasoning") { + this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: part.text }, + }, + }) + } else if (part.type === "tool") { + const toolState = part.state + this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + rawInput: toolState.input, + kind: "other", + status: "pending", + locations: [], + _meta: {}, + }, + }) + + if (toolState.status === "completed" || toolState.status === "error") { + this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: toolState.status === "completed" ? "completed" : "failed", + rawOutput: { content: toolState.status === "completed" ? toolState.output : toolState.error }, + _meta: {}, + }, + }) + } + } + } + } + } + + log.info("replayed_session_history", { sessionId, messageCount: messages.length }) + } catch (error) { + log.error("failed to replay session history", { sessionId, error }) + } + } + async setSessionModel(params: SetSessionModelRequest) { const session = this.sessionManager.get(params.sessionId) diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 63948a8c1ba1..a1f5340cabb4 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,10 +1,22 @@ import { RequestError, type McpServer } from "@agentclientprotocol/sdk" import type { ACPSessionState } from "./types" import { Log } from "@/util/log" +import { Storage } from "@/storage/storage" import type { OpencodeClient } from "@opencode-ai/sdk" const log = Log.create({ service: "acp-session-manager" }) +const STORAGE_PREFIX = "acp_session" as const + +interface StoredACPSession { + id: string + cwd: string + mcpServers: McpServer[] + createdAt: string + model?: { providerID: string; modelID: string } + modeId?: string +} + export class ACPSessionManager { private sessions = new Map() private sdk: OpencodeClient @@ -13,6 +25,44 @@ export class ACPSessionManager { this.sdk = sdk } + private toStorable(state: ACPSessionState): StoredACPSession { + return { + id: state.id, + cwd: state.cwd, + mcpServers: state.mcpServers, + createdAt: state.createdAt instanceof Date ? state.createdAt.toISOString() : state.createdAt, + model: state.model, + modeId: state.modeId, + } + } + + private fromStorable(stored: StoredACPSession): ACPSessionState { + return { + id: stored.id, + cwd: stored.cwd, + mcpServers: stored.mcpServers, + createdAt: new Date(stored.createdAt), + model: stored.model, + modeId: stored.modeId, + } + } + + private async persist(state: ACPSessionState): Promise { + await Storage.write([STORAGE_PREFIX, state.id], this.toStorable(state)) + } + + async load(sessionId: string): Promise { + const cached = this.sessions.get(sessionId) + if (cached) return cached + + const stored = await Storage.read([STORAGE_PREFIX, sessionId]).catch(() => null) + if (!stored) return null + + const state = this.fromStorable(stored) + this.sessions.set(sessionId, state) + return state + } + async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { const session = await this.sdk.session .create({ @@ -39,6 +89,8 @@ export class ACPSessionManager { log.info("creating_session", { state }) this.sessions.set(sessionId, state) + await this.persist(state) + return state } @@ -60,6 +112,8 @@ export class ACPSessionManager { const session = this.get(sessionId) session.model = model this.sessions.set(sessionId, session) + this.persist(session).catch((err) => log.error("failed_to_persist_model_update", { sessionId, err })) + return session } @@ -67,6 +121,8 @@ export class ACPSessionManager { const session = this.get(sessionId) session.modeId = modeId this.sessions.set(sessionId, session) + this.persist(session).catch((err) => log.error("failed_to_persist_mode_update", { sessionId, err })) + return session } } diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 8507228edeaf..95c24b73a7b0 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -11,6 +11,7 @@ export interface ACPSessionState { modelID: string } modeId?: string + internalSessionId?: string } export interface ACPConfig { diff --git a/packages/opencode/test/acp/session.test.ts b/packages/opencode/test/acp/session.test.ts new file mode 100644 index 000000000000..b37b47891aae --- /dev/null +++ b/packages/opencode/test/acp/session.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test, mock } from "bun:test" +import path from "path" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Storage } from "../../src/storage/storage" +import { ACPSessionManager } from "../../src/acp/session" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +function createMockSDK() { + return { + session: { + create: mock(() => + Promise.resolve({ + data: { id: "session_test123" }, + }) + ), + }, + } as any +} + +describe("ACPSessionManager persistence", () => { + test("should persist session to Storage on create", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const sdk = createMockSDK() + const manager = new ACPSessionManager(sdk) + + const session = await manager.create("/test/cwd", [], { + providerID: "test", + modelID: "test-model", + }) + + // Verify session is stored in Storage + const stored = await Storage.read(["acp_session", session.id]).catch(() => null) + expect(stored).not.toBeNull() + expect(stored?.id).toBe(session.id) + expect(stored?.cwd).toBe("/test/cwd") + }, + }) + }) + + test("should load session from Storage if it exists", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const sdk = createMockSDK() + + // Pre-populate storage with a session + const existingSession = { + id: "session_existing", + cwd: "/existing/cwd", + mcpServers: [], + createdAt: new Date().toISOString(), + model: { providerID: "test", modelID: "model" }, + } + await Storage.write(["acp_session", existingSession.id], existingSession) + + const manager = new ACPSessionManager(sdk) + + // Load the existing session + const loaded = await manager.load("session_existing") + + expect(loaded).not.toBeNull() + expect(loaded?.id).toBe("session_existing") + expect(loaded?.cwd).toBe("/existing/cwd") + + // Clean up + await Storage.remove(["acp_session", existingSession.id]) + }, + }) + }) + + test("should return null when loading non-existent session", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const sdk = createMockSDK() + const manager = new ACPSessionManager(sdk) + + const loaded = await manager.load("session_nonexistent") + + expect(loaded).toBeNull() + }, + }) + }) + + test("should update persisted session when model changes", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const sdk = createMockSDK() + const manager = new ACPSessionManager(sdk) + + const session = await manager.create("/test/cwd", []) + manager.setModel(session.id, { providerID: "new", modelID: "new-model" }) + + // Verify Storage was updated + const stored = await Storage.read(["acp_session", session.id]) + expect(stored?.model?.providerID).toBe("new") + expect(stored?.model?.modelID).toBe("new-model") + + // Clean up + await Storage.remove(["acp_session", session.id]) + }, + }) + }) +}) + +