Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -527,6 +536,8 @@ export namespace ACP {
})
}, 0)

await this.replaySessionHistory(sessionId)

return {
sessionId,
models: {
Expand All @@ -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)

Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/src/acp/session.ts
Original file line number Diff line number Diff line change
@@ -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<string, ACPSessionState>()
private sdk: OpencodeClient
Expand All @@ -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<void> {
await Storage.write([STORAGE_PREFIX, state.id], this.toStorable(state))
}

async load(sessionId: string): Promise<ACPSessionState | null> {
const cached = this.sessions.get(sessionId)
if (cached) return cached

const stored = await Storage.read<StoredACPSession>([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<ACPSessionState> {
const session = await this.sdk.session
.create({
Expand All @@ -39,6 +89,8 @@ export class ACPSessionManager {
log.info("creating_session", { state })

this.sessions.set(sessionId, state)
await this.persist(state)

return state
}

Expand All @@ -60,13 +112,17 @@ 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
}

setMode(sessionId: string, modeId: string) {
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
}
}
1 change: 1 addition & 0 deletions packages/opencode/src/acp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ACPSessionState {
modelID: string
}
modeId?: string
internalSessionId?: string
}

export interface ACPConfig {
Expand Down
112 changes: 112 additions & 0 deletions packages/opencode/test/acp/session.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>(["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<any>(["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])
},
})
})
})