Compare commits

..

6 Commits

Author SHA1 Message Date
Brendonovich a241b41545 fix(vcs): report unsupported initialization as 501 2026-09-29 07:14:45 +00:00
Brendonovich 8c46c09f8e feat(vcs): initialize through provider definitions 2026-09-29 06:55:27 +00:00
Brendonovich cb7f6b7ba4 fix(vcs): accept explicit Git init provider 2026-09-29 06:28:15 +00:00
Brendonovich 81c967c4e1 fix(app): restore Git initialization for non-Git projects 2026-09-26 06:28:01 +00:00
opencode-agent[bot] 37049a5a13 fix(tui): skip model selection after MCP connection (#51448)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-26 00:00:25 -05:00
Aiden Cline a64bc3616e refactor(core): cleanup session compaction (#51447) 2026-09-26 00:00:11 -05:00
37 changed files with 1901 additions and 1065 deletions
+1 -1
View File
@@ -184,7 +184,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
@@ -0,0 +1,68 @@
import { base64Encode } from "@opencode/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewWithoutGit"
const sessionID = "ses_review_without_git"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
for (const view of ["desktop", "mobile"] as const) {
test(`offers Git initialization instead of an empty changes selector (${view})`, async ({ page }) => {
test.setTimeout(180_000)
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
const project = {
id: "proj_review_without_git",
worktree: directory,
vcs: undefined as string | undefined,
name: "review-without-git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
const session = {
id: sessionID,
slug: sessionID,
projectID: project.id,
directory,
title: "Review without Git",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
const requests: string[] = []
await page.route("**/api/vcs/init*", (route) => {
requests.push(route.request().url())
project.id = "proj_review_with_git"
project.vcs = "git"
session.projectID = project.id
return route.fulfill({ status: 204 })
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "domcontentloaded" })
if (view === "desktop") {
await expectSessionTitle(page, "Review without Git")
await page.getByRole("button", { name: "Toggle review" }).click()
} else {
await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
}
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator("[data-component='session-review']")
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
const init = panel.getByRole("button", { name: "Create Git repository" })
await expect(init).toBeVisible()
await test.info().attach("review-without-git", { body: await panel.screenshot(), contentType: "image/png" })
await init.click()
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
await expect(init).toHaveCount(0)
await test.info().attach("review-after-git", { body: await panel.screenshot(), contentType: "image/png" })
expect(requests).toHaveLength(1)
expect(new URL(requests[0]!).searchParams.get("location[directory]")).toBe(directory)
expect(new URL(requests[0]!).searchParams.get("provider")).toBe("git")
})
}
+31
View File
@@ -17,6 +17,8 @@ import { createOpenReviewFile } from "../helpers"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { createReviewPanelState } from "./panel-state"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds"
import type { DiffStyle } from "./review-tab"
@@ -42,6 +44,7 @@ export function createSessionReview(input: {
detailsOpen: false,
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
initializingGit: false,
})
const mode = () => input.session.layout.view().review.mode() ?? "git"
const selectedFile = () => input.session.layout.view().review.file()
@@ -161,6 +164,32 @@ export function createSessionReview(input: {
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
const initializeGit = () => {
if (state.initializingGit) return
const directory = location().directory
const sessionID = input.session.identity.params.id
if (!sessionID) return
setState("initializingGit", true)
void server.api.vcs
.init({ location: { directory }, provider: "git" })
.then(async () => {
data.project.invalidate()
data.session.invalidate(sessionID)
data.location.invalidate({ directory })
data.location.vcs.invalidate({ directory })
await data.project.sync()
await data.session.sync(sessionID)
await Promise.all([data.location.sync({ directory }), data.location.vcs.sync({ directory })])
})
.catch((error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t),
}),
)
.finally(() => setState("initializingGit", false))
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
@@ -410,6 +439,8 @@ export function createSessionReview(input: {
diffs,
focusFile,
hasChanges,
initializeGit,
initializingGit: () => state.initializingGit,
loadDiff,
mobile: {
changes: mobileChanges,
+7 -17
View File
@@ -1,4 +1,5 @@
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode/session-ui/v2/session-review-empty-no-git-v2"
import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import { Select } from "@opencode/ui/select"
import { Tabs } from "@opencode/ui/tabs"
@@ -237,7 +238,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview()}>
<Show when={props.review.canReview() && props.review.options().length > 0}>
<Select
options={props.review.options()}
current={props.review.mode()}
@@ -253,7 +254,6 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -264,14 +264,9 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
<Match when={loading()}>
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Match when={props.review.noGit()}>
<div class="h-full flex flex-col">
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
</div>
</Match>
<Match when={true}>
@@ -286,18 +281,13 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Match when={props.review.noGit()}>
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
</Match>
<Match when={true}>
<SessionReviewEmptyChangesV2 />
+8
View File
@@ -2223,6 +2223,13 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type VcsInitInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}
export type VcsInitOutput = void
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
export type VcsGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type VcsGetOutput = { readonly location: Location.PublicRef; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
@@ -2253,6 +2260,7 @@ export type VcsDiffOutput = { readonly location: Location.PublicRef; readonly da
export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect<VcsDiffOutput, E>
export interface VcsApi<E = never> {
readonly init: VcsInitOperation<E>
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
@@ -239,6 +239,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -1450,6 +1452,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointVcsInit = (raw: RawClient["server.vcs"]) => (input?: VcsInitInput) =>
preserveEffect<VcsInitOutput>()(
raw["vcs.init"]({ query: { location: input?.["location"], provider: input?.["provider"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1480,6 +1489,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
init: EndpointVcsInit(raw),
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
@@ -237,6 +237,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -2012,6 +2014,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
init: (input?: VcsInitInput, requestOptions?: RequestOptions) =>
request<VcsInitOutput>(
{
method: "POST",
path: `/api/vcs/init`,
query: { location: input?.["location"], provider: input?.["provider"] },
successStatus: 204,
declaredStatuses: [400, 401, 409, 501, 503],
empty: true,
},
requestOptions,
),
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
@@ -2700,6 +2700,14 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type VcsInitNotSupportedError = {
readonly _tag: "VcsInitNotSupportedError"
readonly providerID: string
readonly message: string
}
export const isVcsInitNotSupportedError = (value: unknown): value is VcsInitNotSupportedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "VcsInitNotSupportedError"
export type ServerInfoOutput = ServerInfo
export type ServerPairOutput = PairingCode
@@ -6249,6 +6257,19 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type VcsInitInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}["location"]
readonly provider?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}["provider"]
}
export type VcsInitOutput = void
export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
@@ -18,7 +18,7 @@ export const Plugin = define({
editor.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { keep: entry.info.compaction.keep.tokens }),
})
}
})
-29
View File
@@ -1,29 +0,0 @@
export * as NativeCompactionPlugin from "./compaction.js"
import { LLMClient, Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect } from "effect"
import { SessionCompaction } from "../session/compaction.js"
import type { PluginInternal } from "./internal.js"
export const Plugin = define({
id: "opencode.compaction.native",
effect: Effect.fn("NativeCompactionPlugin")(function* () {
const llm = yield* LLMClient.Service
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => {
editor.native((input) => {
const request = input.request
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
return Effect.gen(function* () {
const retained = yield* input.retained
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
})
if (LLMClient.canCompact(request))
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
return undefined
})
})
}),
} satisfies PluginInternal.InternalPlugin)
-2
View File
@@ -88,7 +88,6 @@ import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -225,7 +224,6 @@ const pre = [
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
ModelsDevPlugin,
NativeCompactionPlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
+9 -1
View File
@@ -23,7 +23,8 @@ export const Plugin = define({
id: "opencode.vcs.git",
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "git") return
const processes = yield* AppProcess.Service
const adapter = make(processes, {
@@ -35,6 +36,13 @@ export const Plugin = define({
editor.add({
id: "git",
name: "Git",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed"))
}),
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
+14 -1
View File
@@ -26,7 +26,8 @@ export const Plugin = define({
id: "opencode.vcs.hg",
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "hg") return
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
@@ -39,6 +40,18 @@ export const Plugin = define({
editor.add({
id: "hg",
name: "Mercurial",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make("hg", ["init"], {
cwd: input.worktree,
env: { HGPLAIN: "1" },
extendEnv: true,
stdin: "ignore",
}),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Mercurial initialization failed"))
}),
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
File diff suppressed because it is too large Load Diff
+35 -39
View File
@@ -20,6 +20,7 @@ import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionMessageTable } from "../sql.js"
import { SessionTitle } from "../title.js"
import { toSessionError } from "../to-session-error.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
@@ -109,39 +110,39 @@ const layer = Layer.effect(
if (pending?.type === "move")
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
resolveContext: (session) =>
Effect.gen(function* () {
const selected = yield* context.select(session.id)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(
db,
session.id,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session: selected.session,
agent: selected.agent,
tools: selected.tools,
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
}
}),
prepare: context.request.compaction,
messages: yield* store.context(sessionID),
const selected = yield* context.select(sessionID)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(
db,
sessionID,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return yield* compaction.compact({
reason: "manual",
inputID: pending.id,
started: true,
context: {
session: selected.session,
agent: selected.agent,
tools: selected.tools,
model,
initial: history.initial,
messages: history.messages,
},
})
}),
}).pipe(
Effect.catch((error) =>
bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
inputID: pending.id,
error: toSessionError(error),
}),
),
),
).pipe(Effect.exit)
if (Exit.isFailure(compacted)) {
yield* bus.publish(SessionEvent.Compaction.Failed, {
@@ -213,14 +214,9 @@ const layer = Layer.effect(
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
context: loaded,
prepare: context.request.compaction,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const result = yield* compaction.compact(compactionInput)
if (result.status !== "completed") return yield* new StepFailedError({ error: result.error })
if (result.recoveredOverflow) recoverOverflow = false
const compacted = yield* compaction.compact({ reason: "auto", context: loaded })
if (compacted.status === "failed") return yield* new StepFailedError({ error: compacted.error })
if (compacted.status === "completed") {
assistantMessageID = SessionMessage.ID.create()
continue
}
@@ -263,9 +259,9 @@ const layer = Layer.effect(
}),
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
recoverOverflow
? compaction
.compact({ ...compactionInput, overflow: true })
.compact({ reason: "overflow", context: loaded })
.pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
@@ -309,17 +309,14 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
${message.summary}
</summary>
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`,
content: [
"<conversation-checkpoint>",
"The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.",
"",
`<summary>\n${message.summary}\n</summary>`,
...(message.recent ? ["", `<recent-context>\n${message.recent}\n</recent-context>`] : []),
"</conversation-checkpoint>",
].join("\n"),
metadata: message.metadata,
}),
]
+1 -1
View File
@@ -23,7 +23,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_INSTRUCTION =
"You will automatically receive a notification with the command's output when it finishes. DO NOT poll or check on the command while it runs, even if your next step needs its output. NEVER use `sleep`, `ps`, or `pgrep` to wait for it. Every check wastes a turn. Continue with any work that does not depend on the result. If you have nothing else to do, end your turn and the notification will resume you. The output file shown above contains the output so far. Read it only when your work needs its contents. NEVER read it to check whether the command has finished or how far it has gotten."
"You will be notified automatically when the command finishes. The notification will include the command's output. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes."
const OS =
process.platform === "darwin"
? "macOS"
+27 -1
View File
@@ -10,6 +10,7 @@ import { VcsEvent } from "@opencode/schema/vcs-event"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
@@ -20,6 +21,10 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
message: Schema.String,
}) {}
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
kind: Schema.Literals(["missing", "conflict", "unknown", "unsupported", "failed"]),
}) {}
export interface DiffOptions {
readonly context?: number
readonly base?: string
@@ -40,6 +45,7 @@ export interface Adapter {
export interface Interface extends Adapter, State.Transformable<VcsEditor> {
readonly base: () => Effect.Effect<Base | null, DiffError>
readonly initialize: (providerID: string) => Effect.Effect<void, InitializeError>
}
interface Data {
@@ -124,7 +130,9 @@ const layer = Layer.effect(
const provider = selected()
const next: Info = provider
? {
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), {
branch: {},
})),
provider: provider.id,
}
: { branch: {} }
@@ -166,6 +174,24 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
initialize: Effect.fn("Vcs.initialize")(function* (providerID: string) {
if (!(yield* fs.isDir(location.project.directory))) return yield* new InitializeError({ kind: "missing" })
if (yield* Project.root(fs, location.project.directory)) return yield* new InitializeError({ kind: "conflict" })
const provider = state.get().providers.get(providerID)
if (!provider) return yield* new InitializeError({ kind: "unknown" })
if (!provider.init) return yield* new InitializeError({ kind: "unsupported" })
yield* provider
.init(scope)
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause).pipe(Effect.orDie)
: Effect.logWarning("vcs initialization failed", { provider: providerID, cause }).pipe(
Effect.andThen(Effect.fail(new InitializeError({ kind: "failed" }))),
),
),
)
}),
info: Effect.fn("Vcs.info")(function* () {
return current.info
}),
+15 -13
View File
@@ -53,7 +53,11 @@ describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
// An automatic compaction that is not due is skipped.
const due = (input: typeof nearInput) =>
compaction
.compact({ reason: "auto", context: input.context })
.pipe(Effect.map((outcome) => outcome.status !== "skipped"))
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
@@ -73,9 +77,9 @@ describe("ConfigCompactionPlugin.Plugin", () => {
])
yield* ConfigCompactionPlugin.Plugin.effect(host({ event: { subscribe: () => bus.subscribe(Event.Updated) } }))
expect(compaction.required(nearInput)).toBe(false)
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
expect(yield* due(nearInput)).toBe(false)
const ended = yield* bus
.subscribe(SessionEvent.Compaction.Ended)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const messages = [
SessionMessage.User.make({
@@ -92,15 +96,13 @@ describe("ConfigCompactionPlugin.Plugin", () => {
}),
]
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
prepare: modelRequests.compaction,
messages,
yield* compaction.compact({
reason: "manual",
context: { ...nearInput.context, messages },
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
expect(Option.getOrThrow(yield* Fiber.join(started)).data.recent).toContain("Recent context")
expect(Option.getOrThrow(yield* Fiber.join(ended)).data.recent).toContain("Recent context")
yield* config.setEntries([
new Document({
@@ -115,12 +117,12 @@ describe("ConfigCompactionPlugin.Plugin", () => {
yield* bus.publish(Event.Updated, {})
yield* Effect.gen(function* () {
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(nearInput)) return
if (yield* due(nearInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
})
expect(compaction.required(bufferedInput)).toBe(false)
expect(yield* due(bufferedInput)).toBe(false)
yield* config.setEntries([
new Document({
@@ -130,7 +132,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
if (compaction.required(bufferedInput)) return
if (yield* due(bufferedInput)) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for compaction config reload"))
@@ -0,0 +1,48 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/plugin"
import type { VcsDefinition } from "@opencode/plugin/effect/vcs"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { State } from "@opencode/core/state"
import { Effect } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
it.live("Promise VCS initializer reaches the registered provider with its location scope", () =>
Effect.gen(function* () {
const calls: string[] = []
const state = State.create({
initial: () => new Map<string, VcsDefinition>(),
editor: (providers) => ({
add: (definition: VcsDefinition) => providers.set(definition.id, definition),
default: { get: () => undefined, set: (_selection: string) => {} },
}),
})
const context = host()
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "init-vcs",
async setup(ctx) {
await ctx.vcs.transform((editor) =>
editor.add({
id: "custom",
name: "Custom VCS",
init: async (scope, { signal }) => {
expect(signal.aborted).toBe(false)
calls.push(scope.worktree)
},
info: async () => ({ branch: {} }),
branches: async () => [],
status: async () => [],
diff: async () => [],
}),
)
},
}),
)
yield* plugin.effect(host({ vcs: { ...context.vcs, transform: state.transform, reload: state.reload } }))
const init = state.get().get("custom")?.init
if (!init) return yield* Effect.die("Initializer was not registered")
yield* init({ directory: "/workspace", worktree: "/workspace", canonical: "/workspace" })
expect(calls).toEqual(["/workspace"])
}),
)
+164 -153
View File
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { EventTable } from "@opencode/core/event/sql"
import { SessionCompaction } from "@opencode/core/session/compaction"
import type { SessionContext } from "@opencode/core/session/context"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionModelRequest } from "@opencode/core/session/model-request"
@@ -21,9 +22,7 @@ import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
import { App } from "@opencode/core/app"
import { Agent } from "@opencode/core/agent"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Location } from "@opencode/core/location"
import { AbsolutePath } from "@opencode/core/schema"
import { Money } from "@opencode/schema/money"
import { Skill } from "@opencode/schema/skill"
@@ -102,28 +101,38 @@ test("compaction prompt preserves detailed work state and relevant files", () =>
expect(prompt).toContain("## Relevant Files")
})
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
{ type: "text", text: "Image read successfully" },
{
type: "file",
uri: `data:image/png;base64,${base64}`,
mime: "image/png",
name: "pixel.png",
},
])
it.effect("compaction describes tool media without embedding base64", () =>
Effect.gen(function* () {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const recent = yield* recentWithToolOutput(Session.ID.make("ses_tool_media"), [
{ type: "text", text: "Image read successfully" },
{
type: "file",
uri: `data:image/png;base64,${base64}`,
mime: "image/png",
name: "pixel.png",
},
])
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})
expect(recent).toContain("[Tool result]: Image read successfully\n[Attached image/png: pixel.png]")
expect(recent).not.toContain(base64)
}),
)
test("compaction truncation does not split surrogate pairs", () => {
const prefix = "a".repeat(1_999)
it.effect("compaction truncation does not split surrogate pairs", () =>
Effect.gen(function* () {
const prefix = "a".repeat(1_249)
const split = yield* recentWithToolOutput(Session.ID.make("ses_truncate_split"), [
{ type: "text", text: `${prefix}😀suffix` },
])
const whole = yield* recentWithToolOutput(Session.ID.make("ses_truncate_whole"), [
{ type: "text", text: "😀".repeat(1_250) },
])
expect(SessionCompaction.truncateToolOutput(`${prefix}😀suffix`)).toBe(`${prefix}😀\n[truncated]`)
expect(SessionCompaction.truncateToolOutput("😀".repeat(2_000))).toBe("😀".repeat(2_000))
})
expect(split).toEndWith(`[Tool result]: ${prefix}😀\n[truncated]`)
expect(whole).toEndWith(`[Tool result]: ${"😀".repeat(1_250)}`)
}),
)
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt(false)
@@ -156,74 +165,63 @@ test("compaction prompts prohibit task execution", () => {
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => {
const resolved = SessionRunnerModel.resolved(model, {
const session = yield* insertSession(Session.ID.make("ses_input_limit"))
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => ({
session,
model: SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] },
cost: [],
limit,
})
const messages = [
}),
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
model: { id: "summary-model", providerID: "test" },
content: [{ type: "text", text: "Done" }],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
]
return {
session,
resolved,
messages,
context: {
session,
model: resolved,
messages,
agent: {
id: Agent.defaultID,
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
},
initial: "Project instructions.",
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("unused"),
},
},
}
}
],
agent: {
id: Agent.defaultID,
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
},
initial: "Project instructions.",
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("unused"),
},
})
// An automatic compaction that is not due is skipped.
const due = (context: SessionContext.Loaded) =>
compaction.compact({ reason: "auto", context }).pipe(Effect.map((outcome) => outcome.status !== "skipped"))
// 90% of the input limit, which takes precedence over the context window.
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
expect(yield* due(input(244_799, inputLimited))).toBe(false)
expect(yield* due(input(244_800, inputLimited))).toBe(true)
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
return { ...selected, model: { ...selected.model, compaction: { type: "native" as const } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
expect(yield* due(native(244_799))).toBe(false)
expect(yield* due(native(244_800))).toBe(true)
expect(yield* due(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
expect(yield* due(input(89_999, contextLimited))).toBe(false)
expect(yield* due(input(90_000, contextLimited))).toBe(true)
// The reply limit does not lower the ceiling.
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
expect(yield* due(input(89_999, outputLimited))).toBe(false)
expect(yield* due(input(90_000, outputLimited))).toBe(true)
const assistant = input(79_000, contextLimited).messages[0]
const assistant = input(89_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_read",
@@ -231,16 +229,19 @@ it.effect("auto compaction estimates current content against the buffered prompt
state: { status: "completed", input: {}, content: [{ type: "text", text: "x".repeat(4_000) }] },
time: { created: DateTime.makeUnsafe(0) },
})
const grown = { ...input(79_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateTokens(grown)).toBe(80_000)
expect(compaction.required(grown)).toBe(true)
const grown = { ...input(89_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
expect(SessionCompaction.estimateContext(grown)).toBe(90_000)
expect(yield* due(grown)).toBe(true)
const interrupted = { ...assistant, id: SessionMessage.ID.create(), tokens: undefined }
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...grown.messages, interrupted] })).toBe(80_001)
expect(SessionCompaction.estimateContext({ ...grown, messages: [...grown.messages, interrupted] })).toBe(90_001)
// Without provider usage, include 20 tokens for the system prompt, instructions, and tool definition.
expect(SessionCompaction.estimateTokens({ ...grown, messages: [interrupted] })).toBe(21)
expect(SessionCompaction.estimateContext({ ...grown, messages: [interrupted] })).toBe(21)
// Another provider's usage is not trusted either.
const foreign = { ...assistant, model: { ...assistant.model, providerID: Provider.ID.make("other") } }
expect(SessionCompaction.estimateContext({ ...grown, messages: [foreign] })).toBe(21)
expect(
SessionCompaction.estimateTokens({
SessionCompaction.estimateContext({
...grown,
messages: [{ ...interrupted, tokens: input(0, contextLimited).messages[0].tokens }],
}),
@@ -253,7 +254,7 @@ it.effect("auto compaction estimates current content against the buffered prompt
const messages = [
{ ...assistant, content: [{ ...tool, state: { status: "completed" as const, input: {}, content: media } }] },
]
expect(SessionCompaction.estimateTokens({ ...grown, messages })).toBe(82_500)
expect(SessionCompaction.estimateContext({ ...grown, messages })).toBe(92_500)
const user = Schema.decodeUnknownSync(SessionMessage.User)({
id: SessionMessage.ID.create(),
type: "user",
@@ -261,18 +262,18 @@ it.effect("auto compaction estimates current content against the buffered prompt
files: media.map((file) => ({ mime: file.mime, data: "a".repeat(100_000), source: { type: "inline" } })),
time: { created: 0 },
})
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...messages, user] })).toBe(86_000)
expect(SessionCompaction.estimateContext({ ...grown, messages: [...messages, user] })).toBe(96_000)
for (const [modalities, tokens, fallback] of [
[["text", "image"], 82_040, 1_520],
[["text", "pdf"], 83_042, 2_021],
[["text"], 79_082, 41],
[["text", "image"], 92_040, 1_520],
[["text", "pdf"], 93_042, 2_021],
[["text"], 89_082, 41],
] as const) {
const selected = {
...grown,
resolved: { ...grown.resolved, capabilities: { ...grown.resolved.capabilities, input: modalities } },
model: { ...grown.model, capabilities: { ...grown.model.capabilities, input: modalities } },
}
expect(SessionCompaction.estimateTokens({ ...selected, messages: [...messages, user] })).toBe(tokens)
expect(SessionCompaction.estimateTokens({ ...selected, messages: [user] })).toBe(fallback + 20)
expect(SessionCompaction.estimateContext({ ...selected, messages: [...messages, user] })).toBe(tokens)
expect(SessionCompaction.estimateContext({ ...selected, messages: [user] })).toBe(fallback + 20)
}
const checkpoint = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({
@@ -284,7 +285,7 @@ it.effect("auto compaction estimates current content against the buffered prompt
recent: "",
time: { created: 0, completed: 0 },
})
expect(compaction.required({ ...grown, messages: [checkpoint] })).toBe(false)
expect(yield* due({ ...grown, messages: [checkpoint] })).toBe(false)
}),
)
@@ -323,15 +324,66 @@ const loaded = (session: Session.Info, messages: readonly SessionMessage.Info[])
model: resolved,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
initial: "Session instructions",
instructionUpdate: "",
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
})
/** Opens the compaction's message as the runner does when it delivers `/compact`, then compacts. */
const compactManually = (
session: Session.Info,
messages: readonly SessionMessage.Info[],
inputID = SessionMessage.ID.create(),
) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const compaction = yield* SessionCompaction.Service
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: session.id,
reason: "manual",
recent: "",
inputID,
})
return yield* compaction.compact({ reason: "manual", context: loaded(session, messages), inputID })
})
/** The recent text a manual compaction keeps when the latest exchange is one tool call with this output. */
const recentWithToolOutput = (id: Session.ID, content: SessionMessage.ToolStateCompleted["content"]) =>
Effect.gen(function* () {
const session = yield* insertSession(id)
const user = (text: string) =>
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text,
time: { created: DateTime.makeUnsafe(0) },
})
const assistant = Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.create(),
type: "assistant",
agent: Agent.defaultID,
model: { id: "summary-model", providerID: "test" },
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: { status: "completed", input: {}, content },
time: { created: 0 },
},
],
time: { created: 0, completed: 0 },
})
expect(yield* compactManually(session, [user("Earlier question"), user("Read it"), assistant])).toEqual({
status: "completed",
})
const store = yield* SessionStore.Service
const stored = (yield* store.context(id))[0]
return stored?.type === "compaction" && stored.status === "completed" ? stored.recent : ""
})
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_compaction")
@@ -350,7 +402,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
const modelRequests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
let hooked = 0
yield* hooks.register("session", "compaction", (event) =>
@@ -383,15 +434,9 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
expect(yield* compactManually(session, messages, SessionMessage.ID.make("msg_manual_compaction"))).toEqual({
status: "completed",
})
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual([
"## Objective\n- manual summary",
])
@@ -449,12 +494,10 @@ it.effect("compaction hooks can supply the summary instead of the model", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_hooked_compaction")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const messages = [
{
id: SessionMessage.ID.create(),
@@ -474,15 +517,9 @@ it.effect("compaction hooks can supply the summary instead of the model", () =>
}),
)
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_hooked_compaction"),
}),
).toEqual({ status: "completed" })
expect(yield* compactManually(session, messages, SessionMessage.ID.make("msg_hooked_compaction"))).toEqual({
status: "completed",
})
expect(contexts).toBe(0)
expect(requests).toEqual([])
@@ -504,65 +541,45 @@ it.effect("compaction hooks can supply the summary instead of the model", () =>
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
it.effect("native compaction fails without a model call on a route that cannot compact", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
const session = yield* insertSession(Session.ID.make("ses_native_unsupported"))
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Compact this natively.",
time: { created: DateTime.makeUnsafe(0) },
}),
]
expect(
yield* compaction.compactManual({
session,
resolveContext: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.compaction,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
yield* compaction.compact({
reason: "manual",
context: { ...loaded(session, messages), model: { ...resolved, compaction: { type: "native" } } },
inputID: SessionMessage.ID.create(),
}),
).toEqual({
status: "failed",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
error: {
type: "provider.unsupported-operation",
message: "Native compaction is not supported for test/openai-chat",
},
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const sessionID = Session.ID.make("ses_fork_compaction")
const rootID = Session.ID.make("ses_fork_compaction_root")
const session = yield* insertSession(sessionID, {
fork_session_id: rootID,
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
@@ -571,15 +588,9 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
time: { created: DateTime.makeUnsafe(0) },
}),
]
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
).toEqual({ status: "completed" })
expect(yield* compactManually(session, messages, SessionMessage.ID.make("msg_fork_compaction"))).toEqual({
status: "completed",
})
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(rootID)
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
@@ -7,7 +7,6 @@ import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { llmClient } from "@opencode/core/effect/app-node-platform"
import { Instructions } from "@opencode/core/instructions/index"
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
@@ -27,7 +26,6 @@ import { SessionStore } from "@opencode/core/session/store"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(
AppNodeBuilder.build(
@@ -46,7 +44,7 @@ const it = testEffect(
),
)
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean } = {}) {
const endpoint = options.endpoint ?? false
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
@@ -57,7 +55,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
const hooks = yield* PluginHooks.Service
const blocked = Deferred.makeUnsafe<void>()
const hanging = Promise.withResolvers<Response>()
const state = { failure: false, flaky: false, hang: false, overflow: false, localFailure: false, calls: 0 }
const state = { failure: false, flaky: false, hang: false, overflow: false, calls: 0 }
const bodies: Record<string, unknown>[] = []
const headers: Headers[] = []
const server = yield* Effect.acquireRelease(
@@ -87,7 +85,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
)
}
const trigger = JSON.stringify(bodies.at(-1)).includes("compaction_trigger")
if (state.overflow && (trigger || state.localFailure))
if (state.overflow && trigger)
return Response.json(
{
error: {
@@ -114,26 +112,8 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
})
const output = trigger ? [checkpoint] : []
const summary = state.overflow
? [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "summary", role: "assistant", content: [] },
},
{
type: "response.output_text.delta",
item_id: "summary",
output_index: 0,
content_index: 0,
delta: "## Objective\n- Recovered locally",
},
]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
: ""
return new Response(
`${summary}data: ${JSON.stringify({
`data: ${JSON.stringify({
type: "response.completed",
response: {
id: `resp_${state.calls}`,
@@ -188,7 +168,6 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
yield* InstructionState.prepare(db, bus, instructions, sessionID)
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-test-hook"] = event.kind
@@ -218,7 +197,6 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
tools: {
definitions: [
@@ -228,14 +206,11 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
},
}
})
// Opens the compaction's message as the runner does when it delivers `/compact`.
const compact = Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: SessionMessage.ID.create(),
resolveContext: () => load,
prepare: requests.compaction,
})
const inputID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID })
return yield* compaction.compact({ reason: "manual", context: yield* load, inputID })
})
const checkpoint = Effect.gen(function* () {
const messages = (yield* load).messages
@@ -250,8 +225,9 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
})
return {
compact,
automatic: Effect.gen(function* () {
return yield* compaction.compact({ context: yield* load, prepare: requests.compaction })
// An automatic compaction that skips the "is it due" check, which a context this small never passes.
overflow: Effect.gen(function* () {
return yield* compaction.compact({ reason: "overflow", context: yield* load })
}),
checkpoint,
prompt,
@@ -356,7 +332,8 @@ it.live("manual and automatic endpoint compaction keep the provider replacement
const fixture = yield* setup({ endpoint: true })
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(yield* fixture.automatic).toEqual({ status: "completed" })
yield* fixture.prompt("Later user")
expect(yield* fixture.overflow).toEqual({ status: "completed" })
const replacement = SessionProviderContext.decode(yield* fixture.checkpoint)
expect(replacement[0]?.content).toEqual([Message.text("endpoint retained")])
expect(JSON.stringify(replacement)).not.toContain("Original user")
@@ -367,7 +344,7 @@ it.live("manual and automatic endpoint compaction keep the provider replacement
}),
)
it.live("only known automatic native overflow falls back locally and failed recovery retains the checkpoint", () =>
it.live("automatic native failures, interruptions, and overflows retain the checkpoint", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original durable request")
@@ -375,12 +352,12 @@ it.live("only known automatic native overflow falls back locally and failed reco
const installed = yield* fixture.checkpoint
yield* fixture.prompt("Recent request")
fixture.state.failure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(yield* fixture.overflow).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(2)
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.automatic.pipe(Effect.forkScoped)
const pending = yield* fixture.overflow.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
@@ -390,19 +367,12 @@ it.live("only known automatic native overflow falls back locally and failed reco
})
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.hang = false
// Overflow retries natively, with the provider's window, and gives up once nothing is left to shrink.
fixture.state.overflow = true
fixture.state.localFailure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed" })
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.overflow).toMatchObject({ status: "failed", error: { type: "compaction.failed" } })
expect(fixture.state.calls).toBe(4)
expect(JSON.stringify(fixture.bodies[3])).toContain("encrypted_1")
expect(yield* fixture.checkpoint).toEqual(installed)
expect(JSON.stringify(fixture.bodies[4])).toContain("Original durable request")
expect(JSON.stringify(fixture.bodies[4])).not.toContain("encrypted_1")
fixture.state.localFailure = false
expect(yield* fixture.automatic).toEqual({ status: "completed", recoveredOverflow: true })
expect(fixture.state.calls).toBe(7)
expect((yield* fixture.load).messages).toContainEqual(
expect.objectContaining({ type: "compaction", summary: "## Objective\n- Recovered locally" }),
)
}),
)
@@ -451,31 +421,6 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
}),
)
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
Effect.gen(function* () {
const fixture = yield* setup({ plugin: false })
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toMatchObject({
status: "failed",
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
})
yield* fixture.compaction.transform((editor) => {
editor.native(() =>
Effect.succeed({
replacement: [Message.assistant("plugin window")],
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
}),
)
})
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(0)
const installed = yield* fixture.checkpoint
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
}),
)
test("retained user budget counts attachments and drops whole oldest messages", () => {
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
@@ -493,8 +438,8 @@ test("retained user budget counts attachments and drops whole oldest messages",
...user("x".repeat(63_000 * 4)),
files: [{ mime: "image/png", data: "aGVsbG8=", source: { type: "inline" as const } }],
}
expect(SessionCompaction.retainUsers([user("old"), newest], model, 64_000)).toEqual([])
expect(SessionCompaction.recentUserMessages([user("old"), newest], model, 64_000)).toEqual([])
expect(
SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
SessionCompaction.recentUserMessages([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
).toHaveLength(1)
})
@@ -202,6 +202,36 @@ Recent work
])
})
test("leaves out the recent context of a checkpoint that kept none", () => {
const [checkpoint] = toLLMMessages(
[
SessionMessage.Compaction.make({
id: id("compaction"),
type: "compaction",
status: "completed",
reason: "auto",
summary: "Earlier work",
recent: "",
time: { created },
}),
],
model,
)
expect(checkpoint?.content).toEqual([
{
type: "text",
text: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
Earlier work
</summary>
</conversation-checkpoint>`,
},
])
})
describe("model-switched", () => {
const ref = (variant?: string) =>
Model.Ref.make({
+254 -34
View File
@@ -56,7 +56,6 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
import { IdentityPlugin } from "@opencode/core/plugin/identity"
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
import { QuestionTool } from "@opencode/core/tool/plugin/question"
import { Agent } from "@opencode/core/agent"
import { Config } from "@opencode/core/config"
@@ -197,6 +196,8 @@ test("does not apply an ineligible tier without base pricing", () => {
).toBe(Money.USD.zero)
})
const resolvesModel: Effect.Effect<void, SessionRunnerModel.Error> = Effect.void
const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"]) => {
let toolBarrier: ToolBarrier | undefined
const releaseTools = (barrier: ToolBarrier) =>
@@ -206,7 +207,7 @@ const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"])
return {
currentModel: model,
compaction,
modelResolveHook: Effect.void,
modelResolveHook: resolvesModel,
systemBaseline: "Initial context",
systemRemoved: false,
systemUnavailable: false,
@@ -531,7 +532,6 @@ const setup = Effect.gen(function* () {
discard: true,
})
yield* IdentityPlugin.Plugin.effect(pluginHost)
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
@@ -671,6 +671,11 @@ const invalidRequest = () =>
reason: new InvalidRequestError({ message: "Invalid request" }),
})
const payloadTooLarge = () =>
new AIError({
reason: new InvalidRequestError({ message: "Too large", classification: "payload-too-large" }),
})
const rateLimited = (retryAfterMs?: number) =>
new AIError({
reason: new RateLimitError({ message: "Rate limited", retryAfterMs }),
@@ -1296,7 +1301,7 @@ describe("SessionRunnerLLM", () => {
},
)
scenario("delivers controls without preflighting unavailable initial instructions", function* (s) {
scenario("settles compaction and delivers a move while initial instructions are unavailable", function* (s) {
const runner = yield* SessionRunner.Service
s.systemUnavailable = true
let reads = 0
@@ -1323,14 +1328,15 @@ describe("SessionRunnerLLM", () => {
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
expect(reads).toBe(0)
// Compaction needs the model and instructions, so it reads them and fails; the move does not.
expect(reads).toBe(1)
expect(s.requests).toHaveLength(0)
expect(yield* s.inbox).toEqual([])
expect((yield* s.session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
error: { message: "Instruction initialization blocked by unavailable sources: test/context" },
})
})
@@ -2096,7 +2102,7 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(TestLLM.text("## Objective\n- summary", "epoch-summary"))
yield* s.session.compact({ sessionID })
yield* s.resume
expect(systemTexts(s.requests[1])).toEqual(["Changed before compaction"])
expect(systemTexts(s.requests[1])).toEqual([])
expect((yield* s.context).some((message) => message.type === "system")).toBe(false)
s.systemBaseline = "Replacement context"
yield* s.runPrompt("Second")
@@ -2373,7 +2379,6 @@ describe("SessionRunnerLLM", () => {
scenario("explains when manual compaction has no history", function* (s) {
const compaction = yield* s.session.compact({ sessionID })
s.modelResolveHook = Effect.die("model resolution should not run")
yield* s.resume
@@ -2749,22 +2754,16 @@ describe("SessionRunnerLLM", () => {
})
}
for (const response of ["length", "content-filter", "context overflow"] as const) {
for (const response of ["length", "content-filter"] as const) {
scenario(`rejects compaction ${response} without retrying or committing its draft`, function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
yield* s.llm.push(
response === "context overflow"
? Stream.fail(
new AIError({
reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }),
}),
)
: TestLLM.complete(
{ reason: { normalized: response } },
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
),
TestLLM.complete(
{ reason: { normalized: response } },
LLMEvent.textDelta({ id: "truncated", text: "## Objective\n- Incomplete summary" }),
),
)
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
@@ -2778,6 +2777,205 @@ describe("SessionRunnerLLM", () => {
})
}
scenario("stops after three smaller compaction inputs overflow", function* (s) {
// Large enough that the conversation, not the system prompt, is most of the request.
const filler = "context ".repeat(1_000)
yield* s.llm.push(...Array.from({ length: 8 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 8 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${filler}`),
)
s.currentModel = unknownContextModel
s.requests.length = 0
const overflow = () =>
Stream.fail(
new AIError({ reason: new InvalidRequestError({ message: "Too long", classification: "context-overflow" }) }),
)
yield* s.llm.push(overflow(), overflow(), overflow(), overflow())
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(4)
expect(userTexts(s.requests[2])[0].length).toBeLessThan(userTexts(s.requests[1])[0].length)
expect(userTexts(s.requests[3])[0].length).toBeLessThan(userTexts(s.requests[2])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "failed" })
yield* s.llm.push(TestLLM.text("Continued", "continued"))
yield* s.runPrompt("Continue")
expect(userTexts(s.requests[4])).toContain(`Request 0: ${filler}`)
})
scenario("resends compaction as text after payload too large, then shrinks later rejections", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
const filler = "context ".repeat(1_000)
yield* s.session.prompt({
sessionID,
text: "Request 0 with an image",
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(...Array.from({ length: 7 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* s.resume
yield* Effect.forEach(
Array.from({ length: 6 }, (_, index) => index + 1),
(index) => s.runPrompt(`Request ${index}: ${filler}`),
)
s.currentModel = unknownContextModel
s.requests.length = 0
yield* s.llm.push(...Array.from({ length: 5 }, () => Stream.fail(payloadTooLarge())))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
// The first rejection resends everything as text, which carries no media; later ones shrink like "too long".
expect(s.requests).toHaveLength(5)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[image/png omitted]")
expect(userTexts(s.requests[1])[0]).not.toMatch(/older exchanges? omitted/)
expect(userTexts(s.requests[2])[0].length).toBeLessThan(userTexts(s.requests[1])[0].length)
expect(userTexts(s.requests[3])[0].length).toBeLessThan(userTexts(s.requests[2])[0].length)
expect(userTexts(s.requests[4])[0].length).toBeLessThan(userTexts(s.requests[3])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "failed" })
})
scenario("resends whole history as text after payload too large when it cannot be shortened", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
yield* s.session.prompt({
sessionID,
text: `Request 0: ${"x".repeat(40_000)}`,
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(TestLLM.text("Answer 0", "answer-0"))
yield* s.resume
s.currentModel = testModel("smaller-history", { context: 7_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(Stream.fail(payloadTooLarge()), TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
// Even the latest exchange is over the estimated limit, so the first send is unchanged and the second keeps it all.
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[image/png omitted]")
expect(userTexts(s.requests[1])[0]).toContain(`Request 0: ${"x".repeat(40_000)}`)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("shrinks after payload too large on a compaction already sent as text", function* (s) {
const service = yield* SessionCompaction.Service
yield* service.transform((editor) => editor.configure({ buffer: 3_000 }))
s.currentModel = testModel("large-history", { context: 1_000_000, output: 32_000 })
yield* s.llm.push(...Array.from({ length: 6 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 6 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${"x".repeat(8_000)}`),
)
s.currentModel = testModel("smaller-history", { context: 12_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(Stream.fail(payloadTooLarge()), TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
// The first send was already text, so there is no media left to drop; the rejection counts like "too long".
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0].length).toBeLessThan(userTexts(s.requests[0])[0].length)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("aims the first overflow compaction below the rejected context", function* (s) {
const filler = "context ".repeat(1_000)
yield* s.llm.push(...Array.from({ length: 4 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 4 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${filler}`),
)
s.currentModel = recoveryModel
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered", "summary"),
TestLLM.text("Recovered", "recovered"),
)
yield* s.runPrompt("Continue")
// The history fits the model's window by estimate, but the provider just rejected it, so older exchanges go.
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])[0]).toContain("older exchanges omitted")
expect(userTexts(s.requests[1])[0]).not.toContain("Request 0:")
expect(userTexts(s.requests[1])[0]).toContain("Request 3:")
})
scenario("serializes history and omits media after a summary input overflow", function* (s) {
const image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
yield* s.session.prompt({
sessionID,
text: "Earlier question",
files: [{ uri: `data:image/png;base64,${image}` }],
resume: false,
})
yield* s.llm.push(
TestLLM.stop(
LLMEvent.toolCall({ id: "hosted", name: "web_search", input: { query: "earlier" }, providerExecuted: true }),
LLMEvent.toolResult({
id: "hosted",
name: "web_search",
result: { type: "text", value: "x".repeat(5_000) },
providerExecuted: true,
}),
LLMEvent.textStart({ id: "history" }),
LLMEvent.textDelta({ id: "history", text: "Earlier answer" }),
LLMEvent.textEnd({ id: "history" }),
),
)
yield* s.resume
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "Too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered", "summary"),
)
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(2)
expect(s.requests[0]?.messages.some((message) => message.content.some((part) => part.type === "media"))).toBeTrue()
expect(s.requests[1]?.messages.every((message) => message.role === "user")).toBeTrue()
expect(userTexts(s.requests[1])[0]).toContain("[image/png omitted]")
expect(userTexts(s.requests[1])[0]).toContain("[Tool result]:")
expect(userTexts(s.requests[1])[0]).toContain(`${"x".repeat(1_250)}\n[truncated]`)
expect(userTexts(s.requests[1])[0]).not.toContain("x".repeat(1_251))
expect(s.requests[1]?.system).toEqual(s.requests[0]?.system)
expect(s.requests[1]?.tools).toEqual(s.requests[0]?.tools)
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({ status: "completed" })
})
scenario("fits serialized compaction history by omitting oldest exchanges", function* (s) {
const service = yield* SessionCompaction.Service
yield* service.transform((editor) => editor.configure({ buffer: 3_000 }))
s.currentModel = testModel("large-history", { context: 1_000_000, output: 32_000 })
yield* s.llm.push(...Array.from({ length: 4 }, (_, index) => TestLLM.text(`Answer ${index}`, `answer-${index}`)))
yield* Effect.forEach(
Array.from({ length: 4 }, (_, index) => index),
(index) => s.runPrompt(`Request ${index}: ${"x".repeat(8_000)}`),
)
s.currentModel = testModel("smaller-history", { context: 7_000, output: 1_000 })
s.requests.length = 0
yield* s.llm.push(TestLLM.text("## Objective\n- Recovered", "summary"))
const compaction = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(1)
expect(userTexts(s.requests[0])[0]).toContain("older exchanges omitted")
expect(userTexts(s.requests[0])[0]).not.toContain("Request 0:")
expect(userTexts(s.requests[0])[0]).not.toContain("Request 1:")
expect(userTexts(s.requests[0])[0]).toContain("Request 2:")
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "completed",
summary: "## Objective\n- Recovered",
})
})
scenario("records cancelled manual compaction without surfacing an internal failure", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
yield* s.runPrompt("Earlier question")
@@ -2827,6 +3025,30 @@ describe("SessionRunnerLLM", () => {
).toHaveLength(1)
})
scenario("records manual compaction model resolution failures without calling the model", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-unavailable-history"))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
const compaction = yield* s.session.compact({ sessionID })
s.modelResolveHook = Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
)
yield* s.resume
expect(s.requests).toHaveLength(0)
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
})
scenario("automatically compacts into a completed summary and retained recent turn", function* (s) {
const store = yield* SessionStore.Service
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950))
@@ -2875,7 +3097,7 @@ describe("SessionRunnerLLM", () => {
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
modelLimits.set("native", { context: 11_000, output: 1_000 })
s.compaction = { type: "native" }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
@@ -2925,34 +3147,32 @@ describe("SessionRunnerLLM", () => {
expect(JSON.stringify(s.requests[7].messages)).toContain('"encrypted":"second"')
})
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
scenario("recovers an overflowing native window with another native compaction", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
modelLimits.set("native", { context: 42_000, output: 32_000 })
modelLimits.set("native", { context: 11_000, output: 1_000 })
s.compaction = { type: "native" }
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
responseID: `resp_${encrypted}`,
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted },
})
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
CompactionCheckpointResponse.make({
responseID: "resp_native",
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted: "native-window" },
}),
TestLLM.text("After native", "after-native"),
)
yield* s.llm.push(checkpoint("native-window"), TestLLM.text("After native", "after-native"))
yield* s.runPrompt("Before native checkpoint")
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered original history", "local-recovery"),
checkpoint("recovered-window"),
TestLLM.text("Recovered", "recovered"),
)
yield* s.runPrompt("Overflow request")
expect(s.requests).toHaveLength(3)
expect(JSON.stringify(s.requests[0].messages)).toContain("native-window")
expect(JSON.stringify(s.requests[1].messages)).not.toContain("native-window")
expect(userTexts(s.requests[1])).toContain("Original durable request")
expect(userTexts(s.requests[1]).at(-1)).toBe(SessionCompaction.buildPrompt(false))
expect(JSON.stringify(s.requests[2].messages)).toContain("recovered-window")
expect(JSON.stringify(s.requests[2].messages)).not.toContain('"encrypted":"native-window"')
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recovered original history" },
{ type: "compaction", status: "completed", providerContext: { version: 1 } },
{ type: "assistant" },
])
})
+2 -2
View File
@@ -1462,7 +1462,7 @@ describe("ShellTool", () => {
},
{
type: "text",
text: "You will automatically receive a notification with the command's output when it finishes. DO NOT poll or check on the command while it runs, even if your next step needs its output. NEVER use `sleep`, `ps`, or `pgrep` to wait for it. Every check wastes a turn. Continue with any work that does not depend on the result. If you have nothing else to do, end your turn and the notification will resume you. The output file shown above contains the output so far. Read it only when your work needs its contents. NEVER read it to check whether the command has finished or how far it has gotten.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
@@ -1728,7 +1728,7 @@ describe("ShellTool", () => {
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will automatically receive a notification with the command's output when it finishes. DO NOT poll or check on the command while it runs, even if your next step needs its output. NEVER use `sleep`, `ps`, or `pgrep` to wait for it. Every check wastes a turn. Continue with any work that does not depend on the result. If you have nothing else to do, end your turn and the notification will resume you. The output file shown above contains the output so far. Read it only when your work needs its contents. NEVER read it to check whether the command has finished or how far it has gotten.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. Unless the user explicitly asks otherwise, DO NOT poll for completion, even if you need the final result to continue. Repeatedly sleeping and reading or searching the output file is polling, not useful work. You may read the current output if it lets you do useful work now, but do not repeatedly check it while waiting for the command to finish. Keep working on anything that does not depend on the result. If you have nothing else to do, end your response; you will be resumed automatically when the command finishes.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
+23
View File
@@ -103,6 +103,29 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
it.live("routes initialization through the requested provider before a repository exists", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const calls: string[] = []
yield* vcs.transform((editor) => {
editor.add(provider({ id: "read-only" }))
editor.add(
provider({
id: "custom",
init: (input) => Effect.sync(() => void calls.push(input.worktree)),
}),
)
})
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unknown" })
expect(yield* vcs.initialize("read-only").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
expect(calls).toEqual([])
yield* vcs.initialize("custom")
expect(calls).toEqual([directory])
}).pipe(provide(directory)),
),
)
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
Effect.gen(function* () {
+2 -1
View File
@@ -26,6 +26,7 @@ export interface VcsDiffInput extends VcsScope {
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly init?: (input: VcsScope) => Effect.Effect<void, unknown>
readonly info: (input: VcsScope) => Effect.Effect<Vcs.Info, unknown>
readonly base?: (input: VcsScope) => Effect.Effect<Vcs.Base | null, unknown>
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
@@ -33,7 +34,7 @@ export interface VcsDefinition {
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
}
export interface VcsDomain extends VcsApi<unknown> {
export interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
+2
View File
@@ -517,10 +517,12 @@ export function fromPromise(plugin: Plugin) {
host.vcs.transform((editor) => {
callback({
add: (definition) => {
const init = definition.init?.bind(definition)
const base = definition.base?.bind(definition)
editor.add({
id: definition.id,
name: definition.name,
init: init ? (input) => attempt((signal) => init(input, { signal })) : undefined,
info: (input) => attempt((signal) => definition.info(input, { signal })),
base: base ? (input) => attempt((signal) => base(input, { signal })) : undefined,
branches: (input) => attempt((signal) => definition.branches(input, { signal })),
+2 -1
View File
@@ -25,6 +25,7 @@ export interface VcsDiffInput extends VcsScope {
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly init?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<void>
readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Info>
readonly base?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Base | null>
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
@@ -32,7 +33,7 @@ export interface VcsDefinition {
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
}
export interface VcsDomain extends VcsApi {
export interface VcsDomain extends Omit<VcsApi, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Promise<void>
}
+29 -2
View File
@@ -3,9 +3,20 @@ import { Location } from "@opencode/schema/location"
import { NonNegativeInt, PositiveInt, optional } from "@opencode/schema/schema"
import { Vcs } from "@opencode/schema/vcs"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ServiceUnavailableError } from "../errors.js"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
const InitQuery = Schema.Struct({
...LocationQuery.fields,
provider: Schema.optional(Schema.String),
})
export class VcsInitNotSupportedError extends Schema.TaggedError<VcsInitNotSupportedError>()(
"VcsInitNotSupportedError",
{ providerID: Schema.String, message: Schema.String },
{ httpApiStatus: 501 },
) {}
const BranchesQuery = Schema.Struct({
...LocationQuery.fields,
@@ -21,6 +32,22 @@ const DiffQuery = Schema.Struct({
})
export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.post("vcs.init", "/api/vcs/init", {
query: InitQuery,
success: HttpApiSchema.NoContent,
error: [ConflictError, InvalidRequestError, VcsInitNotSupportedError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "vcs.init",
summary: "Initialize VCS repository",
description:
"Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
+43 -2
View File
@@ -1,13 +1,54 @@
import { Vcs } from "@opencode/core/vcs"
import { ServiceUnavailableError } from "@opencode/protocol/errors"
import { Project } from "@opencode/core/project"
import { Plugin } from "@opencode/core/plugin"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
import { VcsInitNotSupportedError } from "@opencode/protocol/groups/vcs"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
const project = yield* Project.Service
const locations = yield* LocationServiceMap.Service
return handlers
.handle("vcs.init", (ctx) =>
Effect.gen(function* () {
const location = yield* Location.Service
const directory = location.project.directory
const providerID = ctx.query.provider ?? "git"
yield* Plugin.awaitActivation
const vcs = yield* Vcs.Service
yield* vcs.initialize(providerID).pipe(
Effect.mapError((error) => {
if (error.kind === "missing")
return new InvalidRequestError({ message: "Project directory does not exist", field: "location" })
if (error.kind === "conflict")
return new ConflictError({ message: "Project already has version control", resource: directory })
if (error.kind === "unknown")
return new InvalidRequestError({
message: "Unknown VCS provider",
field: "provider",
})
if (error.kind === "unsupported")
return new VcsInitNotSupportedError({
providerID,
message: "VCS provider does not support initialization",
})
return new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
}),
)
if (!(yield* project.resolve(directory)).vcs)
return yield* new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
yield* locations.invalidate(
Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
+1
View File
@@ -96,6 +96,7 @@ const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
base: () => Effect.succeed(null),
initialize: () => Effect.fail(new Vcs.InitializeError({ kind: "unsupported" })),
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
info: () => Effect.succeed({ branch: {} }),
+199 -1
View File
@@ -1,6 +1,6 @@
import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { describe, expect } from "bun:test"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { Effect, Layer, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
@@ -8,6 +8,204 @@ import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { ServerFetch } from "../src/fetch"
it.live(
"initializes Git in a markerless project and refreshes its location",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
const before = yield* Effect.promise(() =>
fetch(new URL(`/api/location${url.search}`, server.base), { headers: server.headers }),
)
expect(before.status).toBe(200)
const original = yield* Effect.promise(() => before.json())
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => $`git -C ${tmp.path} rev-parse --is-inside-work-tree`.text())).toBe("true\n")
url.pathname = "/api/location"
const location = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(location.status).toBe(200)
const refreshed = yield* Effect.promise(() => location.json())
expect(refreshed).toMatchObject({ project: { directory: tmp.path } })
expect(refreshed.project.id).not.toBe(original.project.id)
const projects = yield* Effect.promise(() =>
fetch(new URL("/api/project", server.base), { headers: server.headers }),
)
expect(yield* Effect.promise(() => projects.json())).toContainEqual(
expect.objectContaining({ id: refreshed.project.id, vcs: "git" }),
)
url.pathname = "/api/vcs"
const vcs = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "git")
throw new Error("Git provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(vcs).toMatchObject({ data: { provider: "git" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.searchParams.delete("mode")
url.pathname = "/api/vcs/init"
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(repeated.status).toBe(409)
}),
15_000,
)
it.live(
"does not create a project directory while initializing Git",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-missing-")))
const server = yield* startServer(path.join(tmp.path, "config"))
const directory = path.join(tmp.path, "absent")
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", directory)
const response = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(response.status).not.toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git", "HEAD")).exists())).toBe(false)
}),
15_000,
)
it.live(
"accepts an explicit Git provider and rejects unknown providers without initializing",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-provider-")))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "unknown")
const unsupported = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(unsupported.status).toBe(400)
expect(yield* Effect.promise(() => unsupported.json())).toMatchObject({
_tag: "InvalidRequestError",
field: "provider",
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
url.searchParams.set("provider", "git")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(true)
}),
15_000,
)
it.live("returns 501 when a registered VCS provider has no initializer", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-no-init-")))
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SdkPlugins.node.replace(
Layer.succeed(
SdkPlugins.Service,
SdkPlugins.Service.of({
register: () => Effect.void,
all: () => [
{
id: "read-only-vcs",
revision: "test",
effect: (ctx) =>
ctx.vcs
.transform((editor) => {
editor.add({
id: "read-only",
name: "Read-only VCS",
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
})
.pipe(Effect.asVoid),
},
],
}),
),
),
],
},
)
const url = new URL("http://opencode.local/api/vcs/init")
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "read-only")
const response = yield* Effect.promise(() => handler(new Request(url, { method: "POST" })))
expect(response.status).toBe(501)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "VcsInitNotSupportedError",
providerID: "read-only",
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
}),
)
const describeHg = Bun.which("hg") ? describe : describe.skip
describeHg("Mercurial initialization", () => {
it.live(
"initializes and serves an untracked-file diff through the Hg provider",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-hg-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "hg")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(true)
url.pathname = "/api/vcs"
const info = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "hg")
throw new Error("Mercurial provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(info).toMatchObject({ data: { provider: "hg" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.pathname = "/api/vcs/init"
url.searchParams.delete("mode")
url.searchParams.set("provider", "git")
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(repeated.status).toBe(409)
}),
15_000,
)
})
it.live(
"serves lazy review bases, committed diffs, and unavailable-base errors",
() =>
@@ -1012,7 +1012,7 @@ async function connected(
data.location.provider.sync(location),
])
toast.show({ variant: "success", message: `Connected ${integration.name}` })
if (onConnected) {
if (onConnected && integration.metadata?.source !== "mcp") {
onConnected(providerID(data, location, integration.id))
return
}
@@ -1061,6 +1061,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Providers can optionally implement `init(scope)` to create a repository in `scope.worktree`. Initialization selects
the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even when
the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
[`FileDiff.Info`](/api#schema-FileDiff.Info).
@@ -1073,10 +1078,15 @@ interface VcsEditor {
}
}
interface VcsDomain extends VcsApi<unknown> {
interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
interface VcsDefinition {
readonly init?: (scope: VcsScope) => Effect.Effect<void, unknown>
// Existing info, branches, status, and diff callbacks are required.
}
```
### Websearch
@@ -1029,6 +1029,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Providers can optionally implement `init(scope, { signal })` to create a repository in `scope.worktree`. Initialization
selects the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even
when the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
#### Reference
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
@@ -1051,6 +1056,11 @@ interface VcsEditor {
set(providerID: string): void
}
}
interface VcsDefinition {
init?(scope: VcsScope, context: { signal: AbortSignal }): Promise<void>
// Existing info, branches, status, and diff callbacks are required.
}
```
### Worktrees
+3 -2
View File
@@ -85,7 +85,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Compact automatically near the context limit, and recover once when a provider rejects a request as too long. Manual compaction always works. |
| `keep.tokens` | `15000` | Approximate recent conversation kept beside the summary. Larger values preserve more detail but leave less room for new work. |
| `buffer` | `20000` | Tokens to reserve below the model's limit. Larger values start automatic compaction earlier. |
| `buffer` | 10% of the limit | Tokens to keep free below the model's limit. Larger values start automatic compaction earlier. |
`keep.tokens` and `buffer` accept non-negative integers.
@@ -127,7 +127,7 @@ after [ encrypted checkpoint + recent user messages ]
| Support | OpenAI Responses models. Support varies by deployment and model. |
| Threshold | Same `auto` and `buffer` settings as summary compaction. Manual requests work too. |
| Portability | An encrypted checkpoint only works with the same provider, model, and endpoint. If you switch models, the session continues from the original conversation instead. |
| Fallback | If the provider rejects an automatic compaction as too long, OpenCode writes a summary itself. |
| Too long | If the provider rejects a compaction as too long, OpenCode retries it with a smaller request. It does not switch to a summary. |
## Summaries
@@ -156,6 +156,7 @@ session's baseline; see [Instructions](/instructions).
| --- | --- |
| Model | Compaction uses the session's model. There is no separate compaction model. |
| History | Compaction needs older conversation to replace. It cannot create room when a request is mostly fixed instructions and tool schemas. |
| Size | If the conversation to compact is itself too long, OpenCode sends a shortened text version and may leave out the oldest exchanges. |
| Recovery | A request rejected as too long is compacted and retried once. A second rejection is returned as an error. |
| Storage | Earlier messages remain stored even when they are no longer sent to the model. |