feat(acp-next): add session state service (#29240)
This commit is contained in:
218
packages/opencode/src/acp-next/session.ts
Normal file
218
packages/opencode/src/acp-next/session.ts
Normal file
@@ -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<string, KnownMessagePartMetadata>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Info>
|
||||||
|
readonly load: (input: StoreInput) => Effect.Effect<Info>
|
||||||
|
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly setModel: (
|
||||||
|
sessionId: string,
|
||||||
|
model: SelectedModel | undefined,
|
||||||
|
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly setVariant: (
|
||||||
|
sessionId: string,
|
||||||
|
variant: string | undefined,
|
||||||
|
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly setMode: (
|
||||||
|
sessionId: string,
|
||||||
|
modeId: string | undefined,
|
||||||
|
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly recordPartMetadata: (
|
||||||
|
input: RecordPartMetadataInput,
|
||||||
|
) => Effect.Effect<KnownMessagePartMetadata, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly getPartMetadata: (
|
||||||
|
input: PartMetadataLookupInput,
|
||||||
|
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPNextError.SessionNotFoundError>
|
||||||
|
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Session") {}
|
||||||
|
|
||||||
|
type State = Map<string, Info>
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessions = yield* Ref.make<State>(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"
|
||||||
199
packages/opencode/test/acp-next/session.test.ts
Normal file
199
packages/opencode/test/acp-next/session.test.ts
Normal file
@@ -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()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user