Procházet zdrojové kódy

feat(acp-next): add session state service (#29240)

Shoubhit Dash před 2 měsíci
rodič
revize
9dd24d7d03

+ 218 - 0
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<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 - 0
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()
+    }),
+  )
+})