فهرست منبع

refactor(opencode): migrate compaction and workspace tests to layer nodes (#34478)

James Long 2 ماه پیش
والد
کامیت
b9dae8593c

+ 13 - 41
packages/opencode/test/plugin/workspace-adapter.test.ts

@@ -1,63 +1,35 @@
 import { afterEach, describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
-import { FetchHttpClient } from "effect/unstable/http"
-import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Database } from "@opencode-ai/core/database/database"
-import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Npm } from "@opencode-ai/core/npm"
 import { Ripgrep } from "@opencode-ai/core/ripgrep"
-import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import path from "path"
 import { pathToFileURL } from "url"
 import { Auth } from "../../src/auth"
-import { EventV2Bridge } from "../../src/event-v2-bridge"
-import { Config } from "../../src/config/config"
-import { Env } from "../../src/env"
+import { Account } from "../../src/account/account"
 import { RuntimeFlags } from "../../src/effect/runtime-flags"
 import { Workspace } from "../../src/control-plane/workspace"
 import { Plugin } from "../../src/plugin/index"
-import { InstanceBootstrap } from "../../src/project/bootstrap-service"
+import { InstanceBootstrap } from "../../src/project/bootstrap"
 import { InstanceStore } from "../../src/project/instance-store"
-import { Project } from "../../src/project/project"
-import { Vcs } from "../../src/project/vcs"
 import { InstanceState } from "../../src/effect/instance-state"
-import { Session } from "../../src/session/session"
-import { SessionPrompt } from "../../src/session/prompt"
 import { disposeAllInstances, TestInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { AccountTest } from "../fake/account"
 import { AuthTest } from "../fake/auth"
 import { NpmTest } from "../fake/npm"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 
-const configLayer = Config.layer.pipe(
-  Layer.provide(EffectFlock.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(Env.defaultLayer),
-  Layer.provide(AuthTest.empty),
-  Layer.provide(AccountTest.empty),
-  Layer.provide(NpmTest.noop),
-  Layer.provide(FetchHttpClient.layer),
-)
-const pluginLayer = Plugin.layer.pipe(
-  Layer.provide(EventV2Bridge.defaultLayer),
-  Layer.provide(configLayer),
-  Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
-)
 const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
-const workspaceLayer = Workspace.layer.pipe(
-  Layer.provide(Auth.defaultLayer),
-  Layer.provide(Session.defaultLayer),
-  Layer.provide(SessionPrompt.defaultLayer),
-  Layer.provide(Project.defaultLayer),
-  Layer.provide(Vcs.defaultLayer),
-  Layer.provide(FetchHttpClient.layer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(EventV2Bridge.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
-  Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
-)
+
 const it = testEffect(
-  Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
+  AppNodeBuilder.build(LayerNode.group([Plugin.node, Workspace.node, InstanceStore.node, Ripgrep.node]), [
+    [Auth.node, AuthTest.empty],
+    [Account.node, AccountTest.empty],
+    [Npm.node, NpmTest.noop],
+    [InstanceBootstrap.node, noopBootstrapLayer],
+    [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, experimentalWorkspaces: true })],
+  ]),
 )
 
 afterEach(async () => {

+ 24 - 31
packages/opencode/test/project/project.test.ts

@@ -1,5 +1,4 @@
 import { describe, expect } from "bun:test"
-import { EventV2Bridge } from "@/event-v2-bridge"
 import { Project } from "@/project/project"
 import { $ } from "bun"
 import path from "path"
@@ -15,19 +14,17 @@ import { SessionID } from "@/session/schema"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { Cause, Effect, Exit, Layer, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import { NodePath } from "@effect/platform-node"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-import { AppProcess } from "@opencode-ai/core/process"
 import { ProjectV2 } from "@opencode-ai/core/project"
-import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { testEffect } from "../lib/effect"
 import { RuntimeFlags } from "@/effect/runtime-flags"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 
 const encoder = new TextEncoder()
 
-const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)
-const it = testEffect(layer)
+const projectTestNode = LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node])
+const it = testEffect(AppNodeBuilder.build(projectTestNode))
 
 function remoteProjectID(remote: string) {
   return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
@@ -65,41 +62,37 @@ function mockGitFailure(failArg: string) {
         }),
       )
     }),
-  ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
+  ).pipe(Layer.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)))
 }
 
 function projectLayerWithFailure(failArg: string) {
-  return Project.layer.pipe(
-    Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
-    Layer.provide(mockGitFailure(failArg)),
-    Layer.provide(ProjectV2.defaultLayer),
-    Layer.provide(ProjectDirectories.defaultLayer),
-    Layer.provide(EventV2Bridge.defaultLayer),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(NodePath.layer),
-    Layer.provide(Database.defaultLayer),
-    Layer.provide(RuntimeFlags.defaultLayer),
-  )
+  return AppNodeBuilder.build(Project.node, [
+    [ProjectV2.node, projectV2FailureLayer()],
+    [CrossSpawnSpawner.node, mockGitFailure(failArg)],
+  ])
 }
 
-function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.layer>[0]) {
-  return Project.layer.pipe(
-    Layer.provide(EventV2Bridge.defaultLayer),
-    Layer.provide(ProjectV2.defaultLayer),
-    Layer.provide(ProjectDirectories.defaultLayer),
-    Layer.provide(AppProcess.defaultLayer),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(NodePath.layer),
-    Layer.provide(Database.defaultLayer),
-    Layer.provide(RuntimeFlags.layer(flags)),
+function projectV2FailureLayer() {
+  return Layer.succeed(
+    ProjectV2.Service,
+    ProjectV2.Service.of({
+      directories: () => Effect.succeed([]),
+      resolve: (input) =>
+        Effect.succeed({
+          id: ProjectV2.ID.global,
+          directory: input,
+          vcs: { type: "git" as const, store: input },
+        }),
+      commit: () => Effect.void,
+    }),
   )
 }
 
 const failureIt = (failArg: string) =>
-  testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
+  testEffect(AppNodeBuilder.build(projectTestNode, [[Project.node, projectLayerWithFailure(failArg)]]))
 
 const iconDiscoveryIt = testEffect(
-  Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
+  AppNodeBuilder.build(projectTestNode, [[RuntimeFlags.node, RuntimeFlags.layer({ experimentalIconDiscovery: true })]]),
 )
 
 function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Project.Info, never, Project.Service> {

+ 53 - 72
packages/opencode/test/session/compaction.test.ts

@@ -7,12 +7,9 @@ import { APICallError } from "ai"
 import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
 import * as Stream from "effect/Stream"
 import { Config } from "@/config/config"
-import { Image } from "@/image/image"
-import { Agent } from "../../src/agent/agent"
 import { LLM } from "../../src/session/llm"
 import { SessionCompaction } from "../../src/session/compaction"
 import { Token } from "@/util/token"
-import { Permission } from "../../src/permission"
 import { Plugin } from "../../src/plugin"
 import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
 import { Session as SessionNs } from "@/session/session"
@@ -21,12 +18,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { SessionStatus } from "../../src/session/status"
 import { SessionSummary } from "../../src/session/summary"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
+import { SessionProjector } from "@opencode-ai/core/session/projector"
 
-import type { Provider } from "@/provider/provider"
+import { Provider } from "@/provider/provider"
 import * as SessionProcessorModule from "../../src/session/processor"
-import { Snapshot } from "../../src/snapshot"
 import { ProviderTest } from "../fake/provider"
 import { testEffect } from "../lib/effect"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -35,6 +31,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
 import { LLMEvent, Usage } from "@opencode-ai/llm"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ModelV2 } from "@opencode-ai/core/model"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 
 const summary = Layer.succeed(
   SessionSummary.Service,
@@ -210,7 +208,7 @@ function fake(
   } satisfies SessionProcessorModule.SessionProcessor.Handle
 }
 
-function layer(result: "continue" | "compact") {
+function processorLayer(result: "continue" | "compact") {
   return Layer.succeed(
     SessionProcessorModule.SessionProcessor.Service,
     SessionProcessorModule.SessionProcessor.Service.of({
@@ -221,38 +219,28 @@ function layer(result: "continue" | "compact") {
 
 function cfg(compaction?: ConfigV1.Info["compaction"]) {
   const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info
-  return TestConfig.layer({
-    get: () => Effect.succeed({ ...base, compaction }),
-  })
+  return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) }))
 }
 
-const deps = Layer.mergeAll(
-  wide().layer,
-  layer("continue"),
-  Agent.defaultLayer,
-  Plugin.defaultLayer,
-  EventV2Bridge.defaultLayer,
-  Config.defaultLayer,
-  RuntimeFlags.layer({ experimentalEventSystem: true }),
-  Database.defaultLayer,
-  EventV2Bridge.defaultLayer,
-)
-
-const env = Layer.mergeAll(
-  SessionNs.defaultLayer,
-  Database.defaultLayer,
-  EventV2Bridge.defaultLayer,
-  CrossSpawnSpawner.defaultLayer,
-  SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
-)
+const defaultProvider = wide()
+const compactionTestNode = LayerNode.group([
+  SessionCompaction.node,
+  SessionNs.node,
+  SessionProjector.node,
+  Database.node,
+  EventV2Bridge.node,
+  CrossSpawnSpawner.node,
+])
+const env = AppNodeBuilder.build(compactionTestNode, [
+  [Provider.node, defaultProvider.layer],
+  [SessionProcessorModule.SessionProcessor.node, processorLayer("continue")],
+  [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
+])
 
 const it = testEffect(env)
 
-const compactionEnv = Layer.mergeAll(
-  SessionNs.defaultLayer,
-  Database.defaultLayer,
-  EventV2Bridge.defaultLayer,
-  CrossSpawnSpawner.defaultLayer,
+const compactionEnv = AppNodeBuilder.build(
+  LayerNode.group([SessionNs.node, SessionProjector.node, Database.node, EventV2Bridge.node, CrossSpawnSpawner.node]),
 )
 const itCompaction = testEffect(compactionEnv)
 
@@ -260,7 +248,7 @@ type CompactionProcessOptions = {
   result?: "continue" | "compact"
   llm?: Layer.Layer<LLM.Service>
   plugin?: Layer.Layer<Plugin.Service>
-  provider?: ReturnType<typeof ProviderTest.fake>
+  provider?: ReturnType<typeof wide>
   config?: Layer.Layer<Config.Service>
 }
 
@@ -269,30 +257,25 @@ function withCompaction(options?: CompactionProcessOptions) {
 }
 
 function compactionProcessLayer(options?: CompactionProcessOptions) {
-  const events = EventV2Bridge.defaultLayer
-  const status = SessionStatus.layer.pipe(Layer.provide(events))
-  const processor = options?.llm
-    ? SessionProcessorModule.SessionProcessor.layer.pipe(
-        Layer.provide(summary),
-        Layer.provide(Image.defaultLayer),
-        Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
-        Layer.provide(status),
-      )
-    : layer(options?.result ?? "continue")
-  return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe(
-    Layer.provide(SessionNs.defaultLayer),
-    Layer.provide((options?.provider ?? wide()).layer),
-    Layer.provide(Snapshot.defaultLayer),
-    Layer.provide(options?.llm ?? LLM.defaultLayer),
-    Layer.provide(Permission.defaultLayer),
-    Layer.provide(Agent.defaultLayer),
-    Layer.provide(options?.plugin ?? Plugin.defaultLayer),
-    Layer.provide(status),
-    Layer.provide(events),
-    Layer.provide(options?.config ?? Config.defaultLayer),
-    Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
-    Layer.provide(EventV2Bridge.defaultLayer),
-  )
+  const replacements: LayerNode.Replacements = [
+    [Provider.node, (options?.provider ?? wide()).layer],
+    [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
+    [SessionSummary.node, summary],
+  ]
+  if (!options?.llm) {
+    return AppNodeBuilder.build(compactionTestNode, [
+      ...replacements,
+      [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")],
+      ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []),
+      ...(options?.config ? ([[Config.node, options.config]] as const) : []),
+    ])
+  }
+  return AppNodeBuilder.build(compactionTestNode, [
+    ...replacements,
+    [LLM.node, options.llm],
+    ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []),
+    ...(options?.config ? ([[Config.node, options.config]] as const) : []),
+  ])
 }
 
 function createSummaryCompaction(sessionID: SessionID) {
@@ -318,7 +301,7 @@ function llm() {
     push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
       queue.push(stream)
     },
-    layer: Layer.succeed(
+    llmLayer: Layer.succeed(
       LLM.Service,
       LLM.Service.of({
         stream: (input) => {
@@ -614,9 +597,7 @@ describe("session.compaction.create", () => {
         })
 
         const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
-          Effect.provide(SessionV2.defaultLayer),
-          Effect.provide(SessionExecution.noopLayer),
-          Effect.provide(locationServiceMapLayer),
+          Effect.provide(AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])),
         )
         expect(v2.at(-1)).toMatchObject({
           type: "compaction",
@@ -1014,7 +995,7 @@ describe("session.compaction.process", () => {
         expect(part?.type).toBe("compaction")
         expect(part?.tail_start_id).toBeUndefined()
         expect(captured).toContain("yyyy")
-      }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }))
+      }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }))
     },
     { git: true },
   )
@@ -1051,7 +1032,7 @@ describe("session.compaction.process", () => {
         expect(part?.tail_start_id).toBeUndefined()
         expect(captured).toContain("recent image turn")
         expect(captured).toContain("Attached image/png: big.png")
-      }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
+      }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
     },
     { git: true },
   )
@@ -1102,7 +1083,7 @@ describe("session.compaction.process", () => {
         expect(filtered[1]?.info.role).toBe("assistant")
         expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
         expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
-      }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
+      }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
     },
     { git: true },
   )
@@ -1263,7 +1244,7 @@ describe("session.compaction.process", () => {
           expect(Cause.hasInterrupts(exit.cause)).toBe(true)
           expect(Date.now() - start).toBeLessThan(250)
         }
-      }).pipe(withCompaction({ llm: stub.layer }))
+      }).pipe(withCompaction({ llm: stub.llmLayer }))
     },
     { git: true },
     { timeout: 10_000 },
@@ -1336,7 +1317,7 @@ describe("session.compaction.process", () => {
         expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
         // Sanity: the text part still got through.
         expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
-      }).pipe(withCompaction({ llm: stub.layer }))
+      }).pipe(withCompaction({ llm: stub.llmLayer }))
     },
     { git: true },
   )
@@ -1372,7 +1353,7 @@ describe("session.compaction.process", () => {
 
         expect(summary?.info.role).toBe("assistant")
         expect(summary?.parts.some((part) => part.type === "tool")).toBe(false)
-      }).pipe(withCompaction({ llm: stub.layer }))
+      }).pipe(withCompaction({ llm: stub.llmLayer }))
     },
     { git: true },
   )
@@ -1409,7 +1390,7 @@ describe("session.compaction.process", () => {
         expect(captured).not.toContain("keep this turn")
         expect(captured).not.toContain("and this one too")
         expect(captured).not.toContain("What did we do so far?")
-      }).pipe(withCompaction({ llm: stub.layer }))
+      }).pipe(withCompaction({ llm: stub.llmLayer }))
     },
     { git: true },
   )
@@ -1451,7 +1432,7 @@ describe("session.compaction.process", () => {
         expect(captured.match(/summary one/g)?.length).toBe(1)
         expect(captured).toContain("## Constraints & Preferences")
         expect(captured).toContain("## Progress")
-      }).pipe(withCompaction({ llm: stub.layer }))
+      }).pipe(withCompaction({ llm: stub.llmLayer }))
     },
     { git: true },
   )
@@ -1493,7 +1474,7 @@ describe("session.compaction.process", () => {
       expect(
         filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
       ).toBe(true)
-    }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))
+    }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))
   })
 
   itCompaction.instance(