Merge branch 'dev' into brendan/effect-env

This commit is contained in:
Brendan Allan 2026-04-14 09:24:18 +08:00 committed by GitHub
commit 3c74c0db30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 736 additions and 453 deletions

View file

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

View file

@ -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") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) { if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium" 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 // Only set textVerbosity for non-chat gpt-5.x models

View file

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

View file

@ -9,14 +9,12 @@ import z from "zod"
import { Token } from "../util/token" import { Token } from "../util/token"
import { Log } from "../util/log" import { Log } from "../util/log"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db" import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow" import { isOverflow as overflow } from "./overflow"
@ -310,31 +308,51 @@ When constructing the summary, try to stick to this template:
} }
if (!replay) { if (!replay) {
const continueMsg = yield* session.updateMessage({ const info = yield* provider.getProvider(userMessage.model.providerID)
id: MessageID.ascending(), if (
role: "user", (yield* plugin.trigger(
sessionID: input.sessionID, "experimental.compaction.autocontinue",
time: { created: Date.now() }, {
agent: userMessage.agent, sessionID: input.sessionID,
model: userMessage.model, agent: userMessage.agent,
}) model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
const text = provider: {
(input.overflow source: info.source,
? "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" info,
: "") + options: info.options,
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." },
yield* session.updatePart({ message: userMessage,
id: PartID.ascending(), overflow: input.overflow === true,
messageID: continueMsg.id, },
sessionID: input.sessionID, { enabled: true },
type: "text", )).enabled
synthetic: true, ) {
text, const continueMsg = yield* session.updateMessage({
time: { id: MessageID.ascending(),
start: Date.now(), role: "user",
end: Date.now(), 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), 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)),
)
} }

View file

@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@ -598,25 +597,4 @@ export namespace Worktree {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer), 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))
}
} }

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema" 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 * as SessionProcessorModule from "../../src/session/processor"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ProviderTest } from "../fake/provider" import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false }) 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() { function llm() {
const queue: Array< const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>) 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", () => { describe("session.compaction.isOverflow", () => {
test("returns true when token count exceeds usable context", async () => { it.live(
await using tmp = await tmpdir() "returns true when token count exceeds usable context",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 }) const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } } 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 () => { it.live(
await using tmp = await tmpdir() "returns false when token count within usable context",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 200_000, output: 32_000 }) const model = createModel({ context: 200_000, output: 32_000 })
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } 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 () => { it.live(
await using tmp = await tmpdir() "includes cache.read in token count",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 100_000, output: 32_000 }) 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 } } 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 () => { it.live(
await using tmp = await tmpdir() "respects input limit for input caps",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) 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 } } 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 () => { it.live(
await using tmp = await tmpdir() "returns false when input/output are within input caps",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) 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 } } 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 () => { it.live(
await using tmp = await tmpdir() "returns false when output within limit with input caps",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 200_000, input: 120_000, output: 10_000 }) 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 } } 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 ─────────────────────────────────────────── // ─── Bug reproduction tests ───────────────────────────────────────────
// These tests demonstrate that when limit.input is set, isOverflow() // These tests demonstrate that when limit.input is set, isOverflow()
@ -329,11 +362,11 @@ describe("session.compaction.isOverflow", () => {
// Related issues: #10634, #8089, #11086, #12621 // Related issues: #10634, #8089, #11086, #12621
// Open PRs: #6875, #12924 // Open PRs: #6875, #12924
test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => { it.live(
await using tmp = await tmpdir() "BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
// Simulate Claude with prompt caching: input limit = 200K, output limit = 32K // Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
const model = createModel({ context: 200_000, input: 200_000, output: 32_000 }) 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. // With 198K used and only 2K headroom, the next turn will overflow.
// Compaction MUST trigger here. // 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 () => { it.live(
await using tmp = await tmpdir() "BUG: without limit.input, same token count correctly triggers compaction",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
// Same model but without limit.input — uses context - output instead // Same model but without limit.input — uses context - output instead
const model = createModel({ context: 200_000, output: 32_000 }) const model = createModel({ context: 200_000, output: 32_000 })
@ -369,17 +402,17 @@ describe("session.compaction.isOverflow", () => {
// usable = context - output = 200K - 32K = 168K // usable = context - output = 200K - 32K = 168K
// 198K > 168K = true → compaction correctly triggered // 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 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 () => { it.live(
await using tmp = await tmpdir() "BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
// Two models with identical context/output limits, differing only in limit.input // 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 withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
const withoutInputLimit = createModel({ context: 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) // 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 tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit }) const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit }) const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit })
// Both models have identical real capacity — they should agree: // Both models have identical real capacity — they should agree:
expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output) expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K) expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
}, }),
}) ),
}) )
test("returns false when model context limit is 0", async () => { it.live(
await using tmp = await tmpdir() "returns false when model context limit is 0",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const model = createModel({ context: 0, output: 32_000 }) const model = createModel({ context: 0, output: 32_000 })
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } 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 () => { it.live(
await using tmp = await tmpdir({ "returns false when compaction.auto is disabled",
init: async (dir) => { provideTmpdirInstance(
await Bun.write( () =>
path.join(dir, "opencode.json"), Effect.gen(function* () {
JSON.stringify({ const compact = yield* SessionCompaction.Service
compaction: { auto: false }, 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 },
},
}, },
}) ),
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)
},
})
})
}) })
describe("session.compaction.create", () => { describe("session.compaction.create", () => {
test("creates a compaction user message and part", async () => { it.live(
await using tmp = await tmpdir() "creates a compaction user message and part",
await Instance.provide({ provideTmpdirInstance(() =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const session = await Session.create({}) const session = yield* Session.Service
await SessionCompaction.create({ const info = yield* session.create({})
sessionID: session.id,
yield* compact.create({
sessionID: info.id,
agent: "build", agent: "build",
model: ref, model: ref,
auto: true, auto: true,
overflow: 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).toHaveLength(1)
expect(msgs[0].info.role).toBe("user") expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1) expect(msgs[0].parts).toHaveLength(1)
@ -456,60 +488,190 @@ describe("session.compaction.create", () => {
auto: true, auto: true,
overflow: true, overflow: true,
}) })
}, }),
}) ),
}) )
}) })
describe("session.compaction.prune", () => { describe("session.compaction.prune", () => {
test("compacts old completed tool output", async () => { it.live(
await using tmp = await tmpdir() "compacts old completed tool output",
await Instance.provide({ provideTmpdirInstance((dir) =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const session = await Session.create({}) const session = yield* Session.Service
const a = await user(session.id, "first") const info = yield* session.create({})
const b = await assistant(session.id, a.id, tmp.path) const a = yield* session.updateMessage({
await tool(session.id, b.id, "bash", "x".repeat(200_000)) id: MessageID.ascending(),
await user(session.id, "second") role: "user",
await user(session.id, "third") 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") const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool") expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed") expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") { if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber() expect(part.state.time.compacted).toBeNumber()
} }
}, }),
}) ),
}) )
test("skips protected skill tool output", async () => { it.live(
await using tmp = await tmpdir() "skips protected skill tool output",
await Instance.provide({ provideTmpdirInstance((dir) =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const compact = yield* SessionCompaction.Service
const session = await Session.create({}) const session = yield* Session.Service
const a = await user(session.id, "first") const info = yield* session.create({})
const b = await assistant(session.id, a.id, tmp.path) const a = yield* session.updateMessage({
await tool(session.id, b.id, "skill", "x".repeat(200_000)) id: MessageID.ascending(),
await user(session.id, "second") role: "user",
await user(session.id, "third") 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") const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool") expect(part?.type).toBe("tool")
if (part?.type === "tool" && part.state.status === "completed") { if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeUndefined() expect(part.state.time.compacted).toBeUndefined()
} }
}, }),
}) ),
}) )
}) })
describe("session.compaction.process", () => { 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 () => { test("replays the prior user turn on overflow when earlier context exists", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({

View file

@ -304,6 +304,24 @@ export interface Hooks {
input: { sessionID: string }, input: { sessionID: string },
output: { context: string[]; prompt?: string }, output: { context: string[]; prompt?: string },
) => Promise<void> ) => 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"?: ( "experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string }, input: { sessionID: string; messageID: string; partID: string },
output: { text: string }, output: { text: string },