Ver Fonte

Merge branch 'dev' into brendan/effect-env

Brendan Allan há 4 dias atrás
pai
commit
3c74c0db30

+ 12 - 7
packages/opencode/src/control-plane/adaptors/worktree.ts

@@ -1,4 +1,5 @@
 import z from "zod"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Worktree } from "@/worktree"
 import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
 
@@ -12,7 +13,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
   name: "Worktree",
   description: "Create a git worktree",
   async configure(info) {
-    const worktree = await Worktree.makeWorktreeInfo(undefined)
+    const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
     return {
       ...info,
       name: worktree.name,
@@ -22,15 +23,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
   },
   async create(info) {
     const config = WorktreeConfig.parse(info)
-    await Worktree.createFromInfo({
-      name: config.name,
-      directory: config.directory,
-      branch: config.branch,
-    })
+    await AppRuntime.runPromise(
+      Worktree.Service.use((svc) =>
+        svc.createFromInfo({
+          name: config.name,
+          directory: config.directory,
+          branch: config.branch,
+        }),
+      ),
+    )
   },
   async remove(info) {
     const config = WorktreeConfig.parse(info)
-    await Worktree.remove({ directory: config.directory })
+    await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
   },
   target(info) {
     const config = WorktreeConfig.parse(info)

+ 10 - 1
packages/opencode/src/provider/transform.ts

@@ -832,7 +832,16 @@ export namespace ProviderTransform {
     if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
       if (!input.model.api.id.includes("gpt-5-pro")) {
         result["reasoningEffort"] = "medium"
-        result["reasoningSummary"] = "auto"
+        // Only inject reasoningSummary for providers that support it natively.
+        // @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
+        // parameter and return "Unknown parameter: 'reasoningSummary'".
+        if (
+          input.model.api.npm === "@ai-sdk/openai" ||
+          input.model.api.npm === "@ai-sdk/azure" ||
+          input.model.api.npm === "@ai-sdk/github-copilot"
+        ) {
+          result["reasoningSummary"] = "auto"
+        }
       }
 
       // Only set textVerbosity for non-chat gpt-5.x models

+ 3 - 3
packages/opencode/src/server/instance/experimental.ts

@@ -254,7 +254,7 @@ export const ExperimentalRoutes = lazy(() =>
       validator("json", Worktree.CreateInput.optional()),
       async (c) => {
         const body = c.req.valid("json")
-        const worktree = await Worktree.create(body)
+        const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
         return c.json(worktree)
       },
     )
@@ -301,7 +301,7 @@ export const ExperimentalRoutes = lazy(() =>
       validator("json", Worktree.RemoveInput),
       async (c) => {
         const body = c.req.valid("json")
-        await Worktree.remove(body)
+        await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
         await Project.removeSandbox(Instance.project.id, body.directory)
         return c.json(true)
       },
@@ -327,7 +327,7 @@ export const ExperimentalRoutes = lazy(() =>
       validator("json", Worktree.ResetInput),
       async (c) => {
         const body = c.req.valid("json")
-        await Worktree.reset(body)
+        await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
         return c.json(true)
       },
     )

+ 45 - 48
packages/opencode/src/session/compaction.ts

@@ -9,14 +9,12 @@ import z from "zod"
 import { Token } from "../util/token"
 import { Log } from "../util/log"
 import { SessionProcessor } from "./processor"
-import { fn } from "@/util/fn"
 import { Agent } from "@/agent/agent"
 import { Plugin } from "@/plugin"
 import { Config } from "@/config/config"
 import { NotFoundError } from "@/storage/db"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { Effect, Layer, Context } from "effect"
-import { makeRuntime } from "@/effect/run-service"
 import { InstanceState } from "@/effect/instance-state"
 import { isOverflow as overflow } from "./overflow"
 
@@ -310,31 +308,51 @@ When constructing the summary, try to stick to this template:
           }
 
           if (!replay) {
-            const continueMsg = yield* session.updateMessage({
-              id: MessageID.ascending(),
-              role: "user",
-              sessionID: input.sessionID,
-              time: { created: Date.now() },
-              agent: userMessage.agent,
-              model: userMessage.model,
-            })
-            const text =
-              (input.overflow
-                ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
-                : "") +
-              "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
-            yield* session.updatePart({
-              id: PartID.ascending(),
-              messageID: continueMsg.id,
-              sessionID: input.sessionID,
-              type: "text",
-              synthetic: true,
-              text,
-              time: {
-                start: Date.now(),
-                end: Date.now(),
-              },
-            })
+            const info = yield* provider.getProvider(userMessage.model.providerID)
+            if (
+              (yield* plugin.trigger(
+                "experimental.compaction.autocontinue",
+                {
+                  sessionID: input.sessionID,
+                  agent: userMessage.agent,
+                  model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
+                  provider: {
+                    source: info.source,
+                    info,
+                    options: info.options,
+                  },
+                  message: userMessage,
+                  overflow: input.overflow === true,
+                },
+                { enabled: true },
+              )).enabled
+            ) {
+              const continueMsg = yield* session.updateMessage({
+                id: MessageID.ascending(),
+                role: "user",
+                sessionID: input.sessionID,
+                time: { created: Date.now() },
+                agent: userMessage.agent,
+                model: userMessage.model,
+              })
+              const text =
+                (input.overflow
+                  ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
+                  : "") +
+                "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
+              yield* session.updatePart({
+                id: PartID.ascending(),
+                messageID: continueMsg.id,
+                sessionID: input.sessionID,
+                type: "text",
+                synthetic: true,
+                text,
+                time: {
+                  start: Date.now(),
+                  end: Date.now(),
+                },
+              })
+            }
           }
         }
 
@@ -388,25 +406,4 @@ When constructing the summary, try to stick to this template:
       Layer.provide(Config.defaultLayer),
     ),
   )
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
-    return runPromise((svc) => svc.isOverflow(input))
-  }
-
-  export async function prune(input: { sessionID: SessionID }) {
-    return runPromise((svc) => svc.prune(input))
-  }
-
-  export const create = fn(
-    z.object({
-      sessionID: SessionID.zod,
-      agent: z.string(),
-      model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
-      auto: z.boolean(),
-      overflow: z.boolean().optional(),
-    }),
-    (input) => runPromise((svc) => svc.create(input)),
-  )
 }

+ 0 - 22
packages/opencode/src/worktree/index.ts

@@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { NodePath } from "@effect/platform-node"
 import { AppFileSystem } from "@/filesystem"
 import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
-import { makeRuntime } from "@/effect/run-service"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { InstanceState } from "@/effect/instance-state"
 
@@ -598,25 +597,4 @@ export namespace Worktree {
     Layer.provide(AppFileSystem.defaultLayer),
     Layer.provide(NodePath.layer),
   )
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function makeWorktreeInfo(name?: string) {
-    return runPromise((svc) => svc.makeWorktreeInfo(name))
-  }
-
-  export async function createFromInfo(info: Info, startCommand?: string) {
-    return runPromise((svc) => svc.createFromInfo(info, startCommand))
-  }
-
-  export async function create(input?: CreateInput) {
-    return runPromise((svc) => svc.create(input))
-  }
-
-  export async function remove(input: RemoveInput) {
-    return runPromise((svc) => svc.remove(input))
-  }
-
-  export async function reset(input: ResetInput) {
-    return runPromise((svc) => svc.reset(input))
-  }
 }

+ 119 - 89
packages/opencode/test/project/worktree-remove.test.ts

@@ -1,96 +1,126 @@
-import { describe, expect, test } from "bun:test"
 import { $ } from "bun"
-import fs from "fs/promises"
+import { describe, expect } from "bun:test"
+import * as fs from "fs/promises"
 import path from "path"
-import { Instance } from "../../src/project/instance"
+import { Effect, Layer } from "effect"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { Worktree } from "../../src/worktree"
-import { Filesystem } from "../../src/util/filesystem"
-import { tmpdir } from "../fixture/fixture"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
 
-const wintest = process.platform === "win32" ? test : test.skip
+const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
+const wintest = process.platform === "win32" ? it.live : it.live.skip
 
 describe("Worktree.remove", () => {
-  test("continues when git remove exits non-zero after detaching", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const root = tmp.path
-    const name = `remove-regression-${Date.now().toString(36)}`
-    const branch = `opencode/${name}`
-    const dir = path.join(root, "..", name)
-
-    await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
-    await $`git reset --hard`.cwd(dir).quiet()
-
-    const real = (await $`which git`.quiet().text()).trim()
-    expect(real).toBeTruthy()
-
-    const bin = path.join(root, "bin")
-    const shim = path.join(bin, "git")
-    await fs.mkdir(bin, { recursive: true })
-    await Bun.write(
-      shim,
-      [
-        "#!/bin/bash",
-        `REAL_GIT=${JSON.stringify(real)}`,
-        'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
-        '  "$REAL_GIT" "$@" >/dev/null 2>&1',
-        '  echo "fatal: failed to remove worktree: Directory not empty" >&2',
-        "  exit 1",
-        "fi",
-        'exec "$REAL_GIT" "$@"',
-      ].join("\n"),
-    )
-    await fs.chmod(shim, 0o755)
-
-    const prev = process.env.PATH ?? ""
-    process.env.PATH = `${bin}${path.delimiter}${prev}`
-
-    const ok = await (async () => {
-      try {
-        return await Instance.provide({
-          directory: root,
-          fn: () => Worktree.remove({ directory: dir }),
-        })
-      } finally {
-        process.env.PATH = prev
-      }
-    })()
-
-    expect(ok).toBe(true)
-    expect(await Filesystem.exists(dir)).toBe(false)
-
-    const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
-    expect(list).not.toContain(`worktree ${dir}`)
-
-    const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
-    expect(ref.exitCode).not.toBe(0)
-  })
-
-  wintest("stops fsmonitor before removing a worktree", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const root = tmp.path
-    const name = `remove-fsmonitor-${Date.now().toString(36)}`
-    const branch = `opencode/${name}`
-    const dir = path.join(root, "..", name)
-
-    await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
-    await $`git reset --hard`.cwd(dir).quiet()
-    await $`git config core.fsmonitor true`.cwd(dir).quiet()
-    await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
-    await Bun.write(path.join(dir, "tracked.txt"), "next\n")
-    await $`git diff`.cwd(dir).quiet()
-
-    const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()
-    expect(before.exitCode).toBe(0)
-
-    const ok = await Instance.provide({
-      directory: root,
-      fn: () => Worktree.remove({ directory: dir }),
-    })
-
-    expect(ok).toBe(true)
-    expect(await Filesystem.exists(dir)).toBe(false)
-
-    const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
-    expect(ref.exitCode).not.toBe(0)
-  })
+  it.live("continues when git remove exits non-zero after detaching", () =>
+    provideTmpdirInstance(
+      (root) =>
+        Effect.gen(function* () {
+          const svc = yield* Worktree.Service
+          const name = `remove-regression-${Date.now().toString(36)}`
+          const branch = `opencode/${name}`
+          const dir = path.join(root, "..", name)
+
+          yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
+          yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
+
+          const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim()
+          expect(real).toBeTruthy()
+
+          const bin = path.join(root, "bin")
+          const shim = path.join(bin, "git")
+          yield* Effect.promise(() => fs.mkdir(bin, { recursive: true }))
+          yield* Effect.promise(() =>
+            Bun.write(
+              shim,
+              [
+                "#!/bin/bash",
+                `REAL_GIT=${JSON.stringify(real)}`,
+                'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
+                '  "$REAL_GIT" "$@" >/dev/null 2>&1',
+                '  echo "fatal: failed to remove worktree: Directory not empty" >&2',
+                "  exit 1",
+                "fi",
+                'exec "$REAL_GIT" "$@"',
+              ].join("\n"),
+            ),
+          )
+          yield* Effect.promise(() => fs.chmod(shim, 0o755))
+
+          const prev = yield* Effect.acquireRelease(
+            Effect.sync(() => {
+              const prev = process.env.PATH ?? ""
+              process.env.PATH = `${bin}${path.delimiter}${prev}`
+              return prev
+            }),
+            (prev) =>
+              Effect.sync(() => {
+                process.env.PATH = prev
+              }),
+          )
+          void prev
+
+          const ok = yield* svc.remove({ directory: dir })
+
+          expect(ok).toBe(true)
+          expect(
+            yield* Effect.promise(() =>
+              fs
+                .stat(dir)
+                .then(() => true)
+                .catch(() => false),
+            ),
+          ).toBe(false)
+
+          const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text())
+          expect(list).not.toContain(`worktree ${dir}`)
+
+          const ref = yield* Effect.promise(() =>
+            $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
+          )
+          expect(ref.exitCode).not.toBe(0)
+        }),
+      { git: true },
+    ),
+  )
+
+  wintest("stops fsmonitor before removing a worktree", () =>
+    provideTmpdirInstance(
+      (root) =>
+        Effect.gen(function* () {
+          const svc = yield* Worktree.Service
+          const name = `remove-fsmonitor-${Date.now().toString(36)}`
+          const branch = `opencode/${name}`
+          const dir = path.join(root, "..", name)
+
+          yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet())
+          yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet())
+          yield* Effect.promise(() => $`git config core.fsmonitor true`.cwd(dir).quiet())
+          yield* Effect.promise(() => $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow())
+          yield* Effect.promise(() => Bun.write(path.join(dir, "tracked.txt"), "next\n"))
+          yield* Effect.promise(() => $`git diff`.cwd(dir).quiet())
+
+          const before = yield* Effect.promise(() => $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow())
+          expect(before.exitCode).toBe(0)
+
+          const ok = yield* svc.remove({ directory: dir })
+
+          expect(ok).toBe(true)
+          expect(
+            yield* Effect.promise(() =>
+              fs
+                .stat(dir)
+                .then(() => true)
+                .catch(() => false),
+            ),
+          ).toBe(false)
+
+          const ref = yield* Effect.promise(() =>
+            $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
+          )
+          expect(ref.exitCode).not.toBe(0)
+        }),
+      { git: true },
+    ),
+  )
 })

+ 169 - 128
packages/opencode/test/project/worktree.test.ts

@@ -1,16 +1,16 @@
 import { $ } from "bun"
-import { afterEach, describe, expect, test } from "bun:test"
-
-const wintest = process.platform !== "win32" ? test : test.skip
-import fs from "fs/promises"
+import { afterEach, describe, expect } from "bun:test"
+import * as fs from "fs/promises"
 import path from "path"
+import { Cause, Effect, Exit, Layer } from "effect"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { Instance } from "../../src/project/instance"
 import { Worktree } from "../../src/worktree"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
 
-function withInstance(directory: string, fn: () => Promise<any>) {
-  return Instance.provide({ directory, fn })
-}
+const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
+const wintest = process.platform !== "win32" ? it.live : it.live.skip
 
 function normalize(input: string) {
   return input.replace(/\\/g, "/").toLowerCase()
@@ -40,134 +40,175 @@ describe("Worktree", () => {
   afterEach(() => Instance.disposeAll())
 
   describe("makeWorktreeInfo", () => {
-    test("returns info with name, branch, and directory", async () => {
-      await using tmp = await tmpdir({ git: true })
-
-      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
-
-      expect(info.name).toBeDefined()
-      expect(typeof info.name).toBe("string")
-      expect(info.branch).toBe(`opencode/${info.name}`)
-      expect(info.directory).toContain(info.name)
-    })
-
-    test("uses provided name as base", async () => {
-      await using tmp = await tmpdir({ git: true })
-
-      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
-
-      expect(info.name).toBe("my-feature")
-      expect(info.branch).toBe("opencode/my-feature")
-    })
-
-    test("slugifies the provided name", async () => {
-      await using tmp = await tmpdir({ git: true })
-
-      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
-
-      expect(info.name).toBe("my-feature-branch")
-    })
-
-    test("throws NotGitError for non-git directories", async () => {
-      await using tmp = await tmpdir()
-
-      await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
-    })
+    it.live("returns info with name, branch, and directory", () =>
+      provideTmpdirInstance(
+        () =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const info = yield* svc.makeWorktreeInfo()
+
+            expect(info.name).toBeDefined()
+            expect(typeof info.name).toBe("string")
+            expect(info.branch).toBe(`opencode/${info.name}`)
+            expect(info.directory).toContain(info.name)
+          }),
+        { git: true },
+      ),
+    )
+
+    it.live("uses provided name as base", () =>
+      provideTmpdirInstance(
+        () =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const info = yield* svc.makeWorktreeInfo("my-feature")
+
+            expect(info.name).toBe("my-feature")
+            expect(info.branch).toBe("opencode/my-feature")
+          }),
+        { git: true },
+      ),
+    )
+
+    it.live("slugifies the provided name", () =>
+      provideTmpdirInstance(
+        () =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const info = yield* svc.makeWorktreeInfo("My Feature Branch!")
+
+            expect(info.name).toBe("my-feature-branch")
+          }),
+        { git: true },
+      ),
+    )
+
+    it.live("throws NotGitError for non-git directories", () =>
+      provideTmpdirInstance(() =>
+        Effect.gen(function* () {
+          const svc = yield* Worktree.Service
+          const exit = yield* Effect.exit(svc.makeWorktreeInfo())
+
+          expect(Exit.isFailure(exit)).toBe(true)
+          if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
+        }),
+      ),
+    )
   })
 
   describe("create + remove lifecycle", () => {
-    test("create returns worktree info and remove cleans up", async () => {
-      await using tmp = await tmpdir({ git: true })
-
-      const info = await withInstance(tmp.path, () => Worktree.create())
-
-      expect(info.name).toBeDefined()
-      expect(info.branch).toStartWith("opencode/")
-      expect(info.directory).toBeDefined()
-
-      // Wait for bootstrap to complete
-      await Bun.sleep(1000)
-
-      const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
-      expect(ok).toBe(true)
-    })
-
-    test("create returns after setup and fires Event.Ready after bootstrap", async () => {
-      await using tmp = await tmpdir({ git: true })
-      const ready = waitReady()
-
-      const info = await withInstance(tmp.path, () => Worktree.create())
-
-      // create returns before bootstrap completes, but the worktree already exists
-      expect(info.name).toBeDefined()
-      expect(info.branch).toStartWith("opencode/")
-
-      const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
-      const dir = await fs.realpath(info.directory).catch(() => info.directory)
-      expect(normalize(text)).toContain(normalize(dir))
-
-      // Event.Ready fires after bootstrap finishes in the background
-      const props = await ready
-      expect(props.name).toBe(info.name)
-      expect(props.branch).toBe(info.branch)
-
-      // Cleanup
-      await withInstance(info.directory, () => Instance.dispose())
-      await Bun.sleep(100)
-      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
-    })
-
-    test("create with custom name", async () => {
-      await using tmp = await tmpdir({ git: true })
-      const ready = waitReady()
-
-      const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
-
-      expect(info.name).toBe("test-workspace")
-      expect(info.branch).toBe("opencode/test-workspace")
-
-      // Cleanup
-      await ready
-      await withInstance(info.directory, () => Instance.dispose())
-      await Bun.sleep(100)
-      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
-    })
+    it.live("create returns worktree info and remove cleans up", () =>
+      provideTmpdirInstance(
+        () =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const info = yield* svc.create()
+
+            expect(info.name).toBeDefined()
+            expect(info.branch).toStartWith("opencode/")
+            expect(info.directory).toBeDefined()
+
+            yield* Effect.promise(() => Bun.sleep(1000))
+
+            const ok = yield* svc.remove({ directory: info.directory })
+            expect(ok).toBe(true)
+          }),
+        { git: true },
+      ),
+    )
+
+    it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
+      provideTmpdirInstance(
+        (dir) =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const ready = waitReady()
+            const info = yield* svc.create()
+
+            expect(info.name).toBeDefined()
+            expect(info.branch).toStartWith("opencode/")
+
+            const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
+            const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
+            expect(normalize(text)).toContain(normalize(next))
+
+            const props = yield* Effect.promise(() => ready)
+            expect(props.name).toBe(info.name)
+            expect(props.branch).toBe(info.branch)
+
+            yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
+            yield* Effect.promise(() => Bun.sleep(100))
+            yield* svc.remove({ directory: info.directory })
+          }),
+        { git: true },
+      ),
+    )
+
+    it.live("create with custom name", () =>
+      provideTmpdirInstance(
+        () =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const ready = waitReady()
+            const info = yield* svc.create({ name: "test-workspace" })
+
+            expect(info.name).toBe("test-workspace")
+            expect(info.branch).toBe("opencode/test-workspace")
+
+            yield* Effect.promise(() => ready)
+            yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory))
+            yield* Effect.promise(() => Bun.sleep(100))
+            yield* svc.remove({ directory: info.directory })
+          }),
+        { git: true },
+      ),
+    )
   })
 
   describe("createFromInfo", () => {
-    wintest("creates and bootstraps git worktree", async () => {
-      await using tmp = await tmpdir({ git: true })
-
-      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
-      await withInstance(tmp.path, () => Worktree.createFromInfo(info))
-
-      // Worktree should exist in git (normalize slashes for Windows)
-      const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
-      const normalizedList = list.replace(/\\/g, "/")
-      const normalizedDir = info.directory.replace(/\\/g, "/")
-      expect(normalizedList).toContain(normalizedDir)
-
-      // Cleanup
-      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
-    })
+    wintest("creates and bootstraps git worktree", () =>
+      provideTmpdirInstance(
+        (dir) =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const info = yield* svc.makeWorktreeInfo("from-info-test")
+            yield* svc.createFromInfo(info)
+
+            const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
+            const normalizedList = list.replace(/\\/g, "/")
+            const normalizedDir = info.directory.replace(/\\/g, "/")
+            expect(normalizedList).toContain(normalizedDir)
+
+            yield* svc.remove({ directory: info.directory })
+          }),
+        { git: true },
+      ),
+    )
   })
 
   describe("remove edge cases", () => {
-    test("remove non-existent directory succeeds silently", async () => {
-      await using tmp = await tmpdir({ git: true })
-
-      const ok = await withInstance(tmp.path, () =>
-        Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
-      )
-      expect(ok).toBe(true)
-    })
-
-    test("throws NotGitError for non-git directories", async () => {
-      await using tmp = await tmpdir()
-
-      await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
-        "WorktreeNotGitError",
-      )
-    })
+    it.live("remove non-existent directory succeeds silently", () =>
+      provideTmpdirInstance(
+        (dir) =>
+          Effect.gen(function* () {
+            const svc = yield* Worktree.Service
+            const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
+            expect(ok).toBe(true)
+          }),
+        { git: true },
+      ),
+    )
+
+    it.live("throws NotGitError for non-git directories", () =>
+      provideTmpdirInstance(() =>
+        Effect.gen(function* () {
+          const svc = yield* Worktree.Service
+          const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
+
+          expect(Exit.isFailure(exit)).toBe(true)
+          if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
+        }),
+      ),
+    )
   })
 })

+ 360 - 155
packages/opencode/test/session/compaction.test.ts

@@ -13,7 +13,7 @@ import { Instance } from "../../src/project/instance"
 import { Log } from "../../src/util/log"
 import { Permission } from "../../src/permission"
 import { Plugin } from "../../src/plugin"
-import { tmpdir } from "../fixture/fixture"
+import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
 import { Session } from "../../src/session"
 import { MessageV2 } from "../../src/session/message-v2"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -24,6 +24,8 @@ import type { Provider } from "../../src/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 * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 
 Log.init({ print: false })
 
@@ -179,6 +181,23 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
   )
 }
 
+const deps = Layer.mergeAll(
+  ProviderTest.fake().layer,
+  layer("continue"),
+  Agent.defaultLayer,
+  Plugin.defaultLayer,
+  Bus.layer,
+  Config.defaultLayer,
+)
+
+const env = Layer.mergeAll(
+  Session.defaultLayer,
+  CrossSpawnSpawner.defaultLayer,
+  SessionCompaction.layer.pipe(Layer.provide(Session.defaultLayer), Layer.provideMerge(deps)),
+)
+
+const it = testEffect(env)
+
 function llm() {
   const queue: Array<
     Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
@@ -244,78 +263,92 @@ function plugin(ready: ReturnType<typeof defer>) {
   })
 }
 
+function autocontinue(enabled: boolean) {
+  return Layer.mock(Plugin.Service)({
+    trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
+      if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output)
+      return Effect.sync(() => {
+        ;(output as { enabled: boolean }).enabled = enabled
+        return output
+      })
+    },
+    list: () => Effect.succeed([]),
+    init: () => Effect.void,
+  })
+}
+
 describe("session.compaction.isOverflow", () => {
-  test("returns true when token count exceeds usable context", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "returns true when token count exceeds usable context",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 100_000, output: 32_000 })
         const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
+      }),
+    ),
+  )
 
-  test("returns false when token count within usable context", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "returns false when token count within usable context",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 200_000, output: 32_000 })
         const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
+      }),
+    ),
+  )
 
-  test("includes cache.read in token count", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "includes cache.read in token count",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 100_000, output: 32_000 })
         const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
+      }),
+    ),
+  )
 
-  test("respects input limit for input caps", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "respects input limit for input caps",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
         const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
+      }),
+    ),
+  )
 
-  test("returns false when input/output are within input caps", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "returns false when input/output are within input caps",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
         const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
+      }),
+    ),
+  )
 
-  test("returns false when output within limit with input caps", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "returns false when output within limit with input caps",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
         const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
+      }),
+    ),
+  )
 
   // ─── Bug reproduction tests ───────────────────────────────────────────
   // These tests demonstrate that when limit.input is set, isOverflow()
@@ -329,11 +362,11 @@ describe("session.compaction.isOverflow", () => {
   // Related issues: #10634, #8089, #11086, #12621
   // Open PRs: #6875, #12924
 
-  test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         // Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
         const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
 
@@ -350,16 +383,16 @@ describe("session.compaction.isOverflow", () => {
 
         // With 198K used and only 2K headroom, the next turn will overflow.
         // Compaction MUST trigger here.
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
+      }),
+    ),
+  )
 
-  test("BUG: without limit.input, same token count correctly triggers compaction", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "BUG: without limit.input, same token count correctly triggers compaction",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         // Same model but without limit.input — uses context - output instead
         const model = createModel({ context: 200_000, output: 32_000 })
 
@@ -369,17 +402,17 @@ describe("session.compaction.isOverflow", () => {
         // usable = context - output = 200K - 32K = 168K
         // 198K > 168K = true → compaction correctly triggered
 
-        const result = await SessionCompaction.isOverflow({ tokens, model })
+        const result = yield* compact.isOverflow({ tokens, model })
         expect(result).toBe(true) // ← Correct: headroom is reserved
-      },
-    })
-  })
+      }),
+    ),
+  )
 
-  test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         // Two models with identical context/output limits, differing only in limit.input
         const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
         const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
@@ -387,67 +420,66 @@ describe("session.compaction.isOverflow", () => {
         // 170K total tokens — well above context-output (168K) but below input limit (200K)
         const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
 
-        const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit })
-        const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit })
+        const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit })
+        const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit })
 
         // Both models have identical real capacity — they should agree:
         expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
         expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
-      },
-    })
-  })
+      }),
+    ),
+  )
 
-  test("returns false when model context limit is 0", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
+  it.live(
+    "returns false when model context limit is 0",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
         const model = createModel({ context: 0, output: 32_000 })
         const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
-      },
-    })
-  })
+        expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
+      }),
+    ),
+  )
 
-  test("returns false when compaction.auto is disabled", async () => {
-    await using tmp = await tmpdir({
-      init: async (dir) => {
-        await Bun.write(
-          path.join(dir, "opencode.json"),
-          JSON.stringify({
-            compaction: { auto: false },
-          }),
-        )
-      },
-    })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const model = createModel({ context: 100_000, output: 32_000 })
-        const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
-        expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(false)
+  it.live(
+    "returns false when compaction.auto is disabled",
+    provideTmpdirInstance(
+      () =>
+        Effect.gen(function* () {
+          const compact = yield* SessionCompaction.Service
+          const model = createModel({ context: 100_000, output: 32_000 })
+          const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
+          expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
+        }),
+      {
+        config: {
+          compaction: { auto: false },
+        },
       },
-    })
-  })
+    ),
+  )
 })
 
 describe("session.compaction.create", () => {
-  test("creates a compaction user message and part", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
+  it.live(
+    "creates a compaction user message and part",
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
+        const session = yield* Session.Service
 
-        await SessionCompaction.create({
-          sessionID: session.id,
+        const info = yield* session.create({})
+
+        yield* compact.create({
+          sessionID: info.id,
           agent: "build",
           model: ref,
           auto: true,
           overflow: true,
         })
 
-        const msgs = await Session.messages({ sessionID: session.id })
+        const msgs = yield* session.messages({ sessionID: info.id })
         expect(msgs).toHaveLength(1)
         expect(msgs[0].info.role).toBe("user")
         expect(msgs[0].parts).toHaveLength(1)
@@ -456,60 +488,190 @@ describe("session.compaction.create", () => {
           auto: true,
           overflow: true,
         })
-      },
-    })
-  })
+      }),
+    ),
+  )
 })
 
 describe("session.compaction.prune", () => {
-  test("compacts old completed tool output", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const a = await user(session.id, "first")
-        const b = await assistant(session.id, a.id, tmp.path)
-        await tool(session.id, b.id, "bash", "x".repeat(200_000))
-        await user(session.id, "second")
-        await user(session.id, "third")
+  it.live(
+    "compacts old completed tool output",
+    provideTmpdirInstance((dir) =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
+        const session = yield* Session.Service
+        const info = yield* session.create({})
+        const a = yield* session.updateMessage({
+          id: MessageID.ascending(),
+          role: "user",
+          sessionID: info.id,
+          agent: "build",
+          model: ref,
+          time: { created: Date.now() },
+        })
+        yield* session.updatePart({
+          id: PartID.ascending(),
+          messageID: a.id,
+          sessionID: info.id,
+          type: "text",
+          text: "first",
+        })
+        const b: MessageV2.Assistant = {
+          id: MessageID.ascending(),
+          role: "assistant",
+          sessionID: info.id,
+          mode: "build",
+          agent: "build",
+          path: { cwd: dir, root: dir },
+          cost: 0,
+          tokens: {
+            output: 0,
+            input: 0,
+            reasoning: 0,
+            cache: { read: 0, write: 0 },
+          },
+          modelID: ref.modelID,
+          providerID: ref.providerID,
+          parentID: a.id,
+          time: { created: Date.now() },
+          finish: "end_turn",
+        }
+        yield* session.updateMessage(b)
+        yield* session.updatePart({
+          id: PartID.ascending(),
+          messageID: b.id,
+          sessionID: info.id,
+          type: "tool",
+          callID: crypto.randomUUID(),
+          tool: "bash",
+          state: {
+            status: "completed",
+            input: {},
+            output: "x".repeat(200_000),
+            title: "done",
+            metadata: {},
+            time: { start: Date.now(), end: Date.now() },
+          },
+        })
+        for (const text of ["second", "third"]) {
+          const msg = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID: info.id,
+            agent: "build",
+            model: ref,
+            time: { created: Date.now() },
+          })
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: msg.id,
+            sessionID: info.id,
+            type: "text",
+            text,
+          })
+        }
 
-        await SessionCompaction.prune({ sessionID: session.id })
+        yield* compact.prune({ sessionID: info.id })
 
-        const msgs = await Session.messages({ sessionID: session.id })
+        const msgs = yield* session.messages({ sessionID: info.id })
         const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
         expect(part?.type).toBe("tool")
         expect(part?.state.status).toBe("completed")
         if (part?.type === "tool" && part.state.status === "completed") {
           expect(part.state.time.compacted).toBeNumber()
         }
-      },
-    })
-  })
+      }),
+    ),
+  )
 
-  test("skips protected skill tool output", async () => {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const a = await user(session.id, "first")
-        const b = await assistant(session.id, a.id, tmp.path)
-        await tool(session.id, b.id, "skill", "x".repeat(200_000))
-        await user(session.id, "second")
-        await user(session.id, "third")
+  it.live(
+    "skips protected skill tool output",
+    provideTmpdirInstance((dir) =>
+      Effect.gen(function* () {
+        const compact = yield* SessionCompaction.Service
+        const session = yield* Session.Service
+        const info = yield* session.create({})
+        const a = yield* session.updateMessage({
+          id: MessageID.ascending(),
+          role: "user",
+          sessionID: info.id,
+          agent: "build",
+          model: ref,
+          time: { created: Date.now() },
+        })
+        yield* session.updatePart({
+          id: PartID.ascending(),
+          messageID: a.id,
+          sessionID: info.id,
+          type: "text",
+          text: "first",
+        })
+        const b: MessageV2.Assistant = {
+          id: MessageID.ascending(),
+          role: "assistant",
+          sessionID: info.id,
+          mode: "build",
+          agent: "build",
+          path: { cwd: dir, root: dir },
+          cost: 0,
+          tokens: {
+            output: 0,
+            input: 0,
+            reasoning: 0,
+            cache: { read: 0, write: 0 },
+          },
+          modelID: ref.modelID,
+          providerID: ref.providerID,
+          parentID: a.id,
+          time: { created: Date.now() },
+          finish: "end_turn",
+        }
+        yield* session.updateMessage(b)
+        yield* session.updatePart({
+          id: PartID.ascending(),
+          messageID: b.id,
+          sessionID: info.id,
+          type: "tool",
+          callID: crypto.randomUUID(),
+          tool: "skill",
+          state: {
+            status: "completed",
+            input: {},
+            output: "x".repeat(200_000),
+            title: "done",
+            metadata: {},
+            time: { start: Date.now(), end: Date.now() },
+          },
+        })
+        for (const text of ["second", "third"]) {
+          const msg = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID: info.id,
+            agent: "build",
+            model: ref,
+            time: { created: Date.now() },
+          })
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: msg.id,
+            sessionID: info.id,
+            type: "text",
+            text,
+          })
+        }
 
-        await SessionCompaction.prune({ sessionID: session.id })
+        yield* compact.prune({ sessionID: info.id })
 
-        const msgs = await Session.messages({ sessionID: session.id })
+        const msgs = yield* session.messages({ sessionID: info.id })
         const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
         expect(part?.type).toBe("tool")
         if (part?.type === "tool" && part.state.status === "completed") {
           expect(part.state.time.compacted).toBeUndefined()
         }
-      },
-    })
-  })
+      }),
+    ),
+  )
 })
 
 describe("session.compaction.process", () => {
@@ -671,6 +833,49 @@ describe("session.compaction.process", () => {
     })
   })
 
+  test("allows plugins to disable synthetic continue prompt", async () => {
+    await using tmp = await tmpdir()
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const session = await Session.create({})
+        const msg = await user(session.id, "hello")
+        const rt = runtime("continue", autocontinue(false), wide())
+        try {
+          const msgs = await Session.messages({ sessionID: session.id })
+          const result = await rt.runPromise(
+            SessionCompaction.Service.use((svc) =>
+              svc.process({
+                parentID: msg.id,
+                messages: msgs,
+                sessionID: session.id,
+                auto: true,
+              }),
+            ),
+          )
+
+          const all = await Session.messages({ sessionID: session.id })
+          const last = all.at(-1)
+
+          expect(result).toBe("continue")
+          expect(last?.info.role).toBe("assistant")
+          expect(
+            all.some(
+              (msg) =>
+                msg.info.role === "user" &&
+                msg.parts.some(
+                  (part) =>
+                    part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
+                ),
+            ),
+          ).toBe(false)
+        } finally {
+          await rt.dispose()
+        }
+      },
+    })
+  })
+
   test("replays the prior user turn on overflow when earlier context exists", async () => {
     await using tmp = await tmpdir()
     await Instance.provide({

+ 18 - 0
packages/plugin/src/index.ts

@@ -304,6 +304,24 @@ export interface Hooks {
     input: { sessionID: string },
     output: { context: string[]; prompt?: string },
   ) => Promise<void>
+  /**
+   * Called after compaction succeeds and before a synthetic user
+   * auto-continue message is added.
+   *
+   * - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic
+   *   user "continue" turn.
+   */
+  "experimental.compaction.autocontinue"?: (
+    input: {
+      sessionID: string
+      agent: string
+      model: Model
+      provider: ProviderContext
+      message: UserMessage
+      overflow: boolean
+    },
+    output: { enabled: boolean },
+  ) => Promise<void>
   "experimental.text.complete"?: (
     input: { sessionID: string; messageID: string; partID: string },
     output: { text: string },