diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts new file mode 100644 index 000000000000..f14dc469b5bd --- /dev/null +++ b/packages/opencode/src/acp-next/session.ts @@ -0,0 +1,218 @@ +import type { McpServer } from "@agentclientprotocol/sdk" +import { Context, Effect, Layer, Ref } from "effect" +import type { ModelID, ProviderID } from "../provider/schema" +import * as ACPNextError from "./error" + +export type SelectedModel = { + providerID: ProviderID + modelID: ModelID +} + +export type KnownMessagePartMetadata = { + messageId: string + partId: string + toolCallId?: string + metadata?: unknown +} + +export type Info = { + id: string + cwd: string + mcpServers: readonly McpServer[] + createdAt: Date + model?: SelectedModel + variant?: string + modeId?: string + knownParts: ReadonlyMap +} + +export type StoreInput = { + id: string + cwd: string + mcpServers?: readonly McpServer[] + createdAt?: Date + model?: SelectedModel + variant?: string + modeId?: string +} + +export type RecordPartMetadataInput = { + sessionId: string + messageId: string + partId: string + toolCallId?: string + metadata?: unknown +} + +export type PartMetadataLookupInput = { + sessionId: string + messageId: string + partId: string +} + +export type Interface = { + readonly create: (input: StoreInput) => Effect.Effect + readonly load: (input: StoreInput) => Effect.Effect + readonly get: (sessionId: string) => Effect.Effect + readonly tryGet: (sessionId: string) => Effect.Effect + readonly remove: (sessionId: string) => Effect.Effect + readonly setModel: ( + sessionId: string, + model: SelectedModel | undefined, + ) => Effect.Effect + readonly getModel: (sessionId: string) => Effect.Effect + readonly setVariant: ( + sessionId: string, + variant: string | undefined, + ) => Effect.Effect + readonly getVariant: (sessionId: string) => Effect.Effect + readonly setMode: ( + sessionId: string, + modeId: string | undefined, + ) => Effect.Effect + readonly getMode: (sessionId: string) => Effect.Effect + readonly recordPartMetadata: ( + input: RecordPartMetadataInput, + ) => Effect.Effect + readonly getPartMetadata: ( + input: PartMetadataLookupInput, + ) => Effect.Effect + readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ACPNext/Session") {} + +type State = Map + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Ref.make(new Map()) + + const store = Effect.fn("ACPNext.Session.store")(function* (input: StoreInput) { + const session = makeSession(input) + yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) + return snapshot(session) + }) + + const tryGet = Effect.fn("ACPNext.Session.tryGet")(function* (sessionId: string) { + const session = (yield* Ref.get(sessions)).get(sessionId) + if (!session) return + return snapshot(session) + }) + + const get = Effect.fn("ACPNext.Session.get")(function* (sessionId: string) { + const session = yield* tryGet(sessionId) + if (session) return session + return yield* new ACPNextError.SessionNotFoundError({ sessionId }) + }) + + const update = Effect.fn("ACPNext.Session.update")(function* ( + sessionId: string, + fn: (session: Info) => Info, + ) { + const result = yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = fn(session) + return [snapshot(next), new Map(state).set(sessionId, next)] as const + }) + if (result) return result + return yield* new ACPNextError.SessionNotFoundError({ sessionId }) + }) + + const remove = Effect.fn("ACPNext.Session.remove")(function* (sessionId: string) { + return yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = new Map(state) + next.delete(sessionId) + return [snapshot(session), next] as const + }) + }) + + const setModel: Interface["setModel"] = Effect.fn("ACPNext.Session.setModel")((sessionId, model) => + update(sessionId, (session) => ({ ...session, model })), + ) + + const setVariant: Interface["setVariant"] = Effect.fn("ACPNext.Session.setVariant")((sessionId, variant) => + update(sessionId, (session) => ({ ...session, variant })), + ) + + const setMode: Interface["setMode"] = Effect.fn("ACPNext.Session.setMode")((sessionId, modeId) => + update(sessionId, (session) => ({ ...session, modeId })), + ) + + const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACPNext.Session.recordPartMetadata")( + (input) => { + const metadata = { + messageId: input.messageId, + partId: input.partId, + toolCallId: input.toolCallId, + metadata: input.metadata, + } + return update(input.sessionId, (session) => ({ + ...session, + knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), + })).pipe(Effect.as(metadata)) + }, + ) + + return Service.of({ + create: store, + load: store, + get, + tryGet, + remove, + setModel, + getModel: Effect.fn("ACPNext.Session.getModel")(function* (sessionId) { + return (yield* get(sessionId)).model + }), + setVariant, + getVariant: Effect.fn("ACPNext.Session.getVariant")(function* (sessionId) { + return (yield* get(sessionId)).variant + }), + setMode, + getMode: Effect.fn("ACPNext.Session.getMode")(function* (sessionId) { + return (yield* get(sessionId)).modeId + }), + recordPartMetadata, + getPartMetadata: Effect.fn("ACPNext.Session.getPartMetadata")(function* (input) { + return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) + }), + tryGetPartMetadata: Effect.fn("ACPNext.Session.tryGetPartMetadata")(function* (input) { + return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) + }), + }) + }), +) + +export const defaultLayer = layer + +function makeSession(input: StoreInput): Info { + return { + id: input.id, + cwd: input.cwd, + mcpServers: [...(input.mcpServers ?? [])], + createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), + model: input.model, + variant: input.variant, + modeId: input.modeId, + knownParts: new Map(), + } +} + +function snapshot(session: Info): Info { + return { + ...session, + mcpServers: [...session.mcpServers], + createdAt: new Date(session.createdAt), + knownParts: new Map(session.knownParts), + } +} + +function partMetadataKey(input: { messageId: string; partId: string }) { + return `${input.messageId}:${input.partId}` +} + +export * as ACPNextSession from "./session" diff --git a/packages/opencode/test/acp-next/session.test.ts b/packages/opencode/test/acp-next/session.test.ts new file mode 100644 index 000000000000..0c1cb16cc784 --- /dev/null +++ b/packages/opencode/test/acp-next/session.test.ts @@ -0,0 +1,199 @@ +import { describe, expect } from "bun:test" +import type { McpServer } from "@agentclientprotocol/sdk" +import { Effect } from "effect" +import * as ACPNextError from "@/acp-next/error" +import * as ACPNextSession from "@/acp-next/session" +import { ModelID, ProviderID } from "@/provider/schema" +import { testEffect } from "../lib/effect" + +const sessionTest = testEffect(ACPNextSession.defaultLayer) + +const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({ + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), +}) + +const mcpServer: McpServer = { + name: "local-tools", + command: "node", + args: ["server.js"], + env: [], +} + +describe("acp-next session state", () => { + sessionTest.effect("creates and retrieves session state", () => + Effect.gen(function* () { + const createdAt = new Date("2026-05-25T00:00:00.000Z") + const created = yield* ACPNextSession.Service.use((session) => + session.create({ + id: "ses_1", + cwd: "/workspace", + mcpServers: [mcpServer], + createdAt, + model: model("anthropic", "claude-sonnet"), + variant: "high", + modeId: "build", + }), + ) + const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1")) + + expect(created).toMatchObject({ + id: "ses_1", + cwd: "/workspace", + mcpServers: [mcpServer], + model: model("anthropic", "claude-sonnet"), + variant: "high", + modeId: "build", + }) + expect(loaded.createdAt).toEqual(createdAt) + expect(loaded.knownParts.size).toBe(0) + }), + ) + + sessionTest.effect("fails required lookups with typed SessionNotFound", () => + Effect.gen(function* () { + const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError) + expect(error.sessionId).toBe("ses_missing") + }), + ) + + sessionTest.effect("tryGet lets event routing ignore unknown sessions", () => + Effect.gen(function* () { + const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing")) + const missingPart = yield* ACPNextSession.Service.use((session) => + session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }), + ) + + expect(missing).toBeUndefined() + expect(missingPart).toBeUndefined() + }), + ) + + sessionTest.effect("updates selected model while preserving session identity and inputs", () => + Effect.gen(function* () { + yield* ACPNextSession.Service.use((session) => + session.create({ + id: "ses_model", + cwd: "/workspace", + mcpServers: [mcpServer], + model: model("anthropic", "claude-sonnet"), + variant: "high", + modeId: "build", + }), + ) + + const updated = yield* ACPNextSession.Service.use((session) => + session.setModel("ses_model", model("openai", "gpt-5")), + ) + + expect(updated.id).toBe("ses_model") + expect(updated.cwd).toBe("/workspace") + expect(updated.mcpServers).toEqual([mcpServer]) + expect(updated.model).toEqual(model("openai", "gpt-5")) + expect(updated.variant).toBe("high") + expect(updated.modeId).toBe("build") + }), + ) + + sessionTest.effect("updates selected variant and mode independently", () => + Effect.gen(function* () { + yield* ACPNextSession.Service.use((session) => + session.load({ + id: "ses_config", + cwd: "/workspace", + model: model("anthropic", "claude-sonnet"), + variant: "low", + modeId: "plan", + }), + ) + + yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high")) + expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") + expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan") + + yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build")) + expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") + expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build") + }), + ) + + sessionTest.effect("records known message part metadata for delta routing", () => + Effect.gen(function* () { + yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" })) + + const metadata = yield* ACPNextSession.Service.use((session) => + session.recordPartMetadata({ + sessionId: "ses_parts", + messageId: "msg_1", + partId: "part_1", + toolCallId: "tool_1", + metadata: { output: "first chunk" }, + }), + ) + const routed = yield* ACPNextSession.Service.use((session) => + session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }), + ) + + expect(metadata).toEqual({ + messageId: "msg_1", + partId: "part_1", + toolCallId: "tool_1", + metadata: { output: "first chunk" }, + }) + expect(routed).toEqual(metadata) + }), + ) + + sessionTest.effect("keeps repeated part ids distinct across messages", () => + Effect.gen(function* () { + yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" })) + yield* ACPNextSession.Service.use((session) => + session.recordPartMetadata({ + sessionId: "ses_duplicate_parts", + messageId: "msg_1", + partId: "part_1", + metadata: { output: "from first message" }, + }), + ) + yield* ACPNextSession.Service.use((session) => + session.recordPartMetadata({ + sessionId: "ses_duplicate_parts", + messageId: "msg_2", + partId: "part_1", + metadata: { output: "from second message" }, + }), + ) + + const first = yield* ACPNextSession.Service.use((session) => + session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }), + ) + const second = yield* ACPNextSession.Service.use((session) => + session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }), + ) + + expect(first?.metadata).toEqual({ output: "from first message" }) + expect(second?.metadata).toEqual({ output: "from second message" }) + }), + ) + + sessionTest.effect("removing a session clears its known part metadata", () => + Effect.gen(function* () { + yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" })) + yield* ACPNextSession.Service.use((session) => + session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), + ) + + const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove")) + const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove")) + const missingPart = yield* ACPNextSession.Service.use((session) => + session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), + ) + + expect(removed?.knownParts.size).toBe(1) + expect(missing).toBeUndefined() + expect(missingPart).toBeUndefined() + }), + ) +})