Compare commits

...
13 Commits
Author SHA1 Message Date
Shoubhit Dash 54504ab3a5 fix(client): synthesize idle messages live
The solid data layer mirrors every projected marker message from its event so the in-memory transcript matches the server before the next read; do the same for the idle marker on execution succeeded, failed, and non-shutdown interrupted.
2026-09-07 23:57:17 +05:30
Shoubhit Dash cc5086d127 feat(session): add turn diff route
GET /api/session/:sessionID/diff?messageID&to&context returns FileDiff.Info[] for the turn containing a user message (default: the newest one), or the contiguous range through a later user message's turn. A turn runs from the first prompt after the Session was last idle until its idle marker, so steers belong to the turn they interrupted; Sessions without markers fall back to prompt-to-next-prompt. The diff compares the range's first recorded step snapshot with its last recorded one, or with the working copy only while the Session is actively executing, resolves the snapshot repository from the Location in effect at the range (rejecting ranges that span a move), and defaults to full-file patches like vcs.diff. Shared missingMessage and failedSnapshot handler helpers replace the inlined mappings in the session handlers.
2026-09-07 22:08:19 +05:30
Shoubhit Dash b20482461c feat(session): record idle boundaries as messages
Project an idle message when a busy period ends (execution succeeded, failed, or interrupted for any reason other than shutdown, which resumes the same turn). Every step since the previous marker is one turn, including prompts steered in while the Session was busy, so turns are derivable from session_message alone without persisting events or a separate table. The marker is invisible to the model and to the TUI and web transcripts.
2026-09-07 22:00:36 +05:30
Shoubhit Dash 5b5368fe98 perf(core): batch snapshot tree diffs
Git.tree.diff ran --name-status, --numstat, and a patch once per changed file, sequentially, so a turn or revert touching N files cost 1 + 3N git processes (~50ms per file). Run the three once over the tree pair, split the patch with VcsPatch.chunksByFile, cap patch output at MAX_TOTAL_PATCH_BYTES like VCS diffs (capped files get an empty patch, stats stay exact), keep core.quotepath=false so non-ASCII paths still match their chunk, and pass --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice and passing every path as a pathspec.
2026-09-07 21:53:19 +05:30
opencode-agent[bot]andrekram1-node 582a2108ce fix(tui): finish reasoning rows on end event (#47813)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-07 11:12:33 -05:00
OpeOginniandAiden Cline 9c65a69937 fix(core): support granular webfetch permissions (#46611)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-07 11:03:21 -05:00
Aiden Cline 1d391908f4 feat(core): identify to MCP authorization servers with a client metadata document (#47743) 2026-09-07 10:54:29 -05:00
Filip 596dca4dee feat(cli): add session list and delete commands (#47812) 2026-09-07 17:43:56 +02:00
Shoubhit Dash 1827832775 fix(core): retry transient provider compaction failures (#47806) 2026-09-07 21:03:43 +05:30
Shoubhit Dash 898692af26 feat(core): schedule provider compaction automatically (#47324) 2026-09-07 19:27:11 +05:30
Kit Langton 5c3f2ddf8c refactor(core): unify filesystem access policy (#47630) 2026-09-07 09:26:55 -04:00
Shoubhit Dash 1382cebe10 feat(core): support explicit provider compaction (#47323) 2026-09-07 18:20:35 +05:30
Shoubhit Dash 0732cdd8e1 feat(core): persist provider compaction context (#47322) 2026-09-07 18:16:16 +05:30
92 changed files with 4311 additions and 790 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
build.onLoad({ filter: /filesystem[/\\]watcher-binding\.ts$/ }, () => ({
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
loader: "js",
}))
+28
View File
@@ -361,6 +361,34 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
...PermissionParams,
},
}),
Spec.make("session", {
description: "Manage sessions",
commands: [
Spec.make("list", {
description: "List top-level sessions in the current project, newest first",
params: {
...ServerParams,
maxCount: Flag.integer("max-count").pipe(
Flag.withAlias("n"),
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
Flag.withDescription("Limit to N most recent sessions (default: 100)"),
Flag.optional,
),
format: Flag.choice("format", ["table", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("table"),
),
},
}),
Spec.make("delete", {
description: "Delete a session and its child sessions",
params: {
...ServerParams,
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
},
}),
],
}),
Spec.make("service", {
description: "Manage the background server",
commands: [
@@ -0,0 +1,34 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
const handler = Effect.fn("cli.session.delete")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.delete>,
) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
yield* Effect.tryPromise({
try: (signal) => client.session.remove({ sessionID: input.sessionID }, { signal }),
catch: (cause) => cause,
})
process.stdout.write(`Session ${input.sessionID} deleted${EOL}`)
})
export default Runtime.handler(Commands.commands.session.commands.delete, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -0,0 +1,113 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option, Stream } from "effect"
import { EOL } from "node:os"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
const handler = Effect.fn("cli.session.list")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.list>,
) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
const location = yield* Effect.tryPromise({
try: (signal) => client.location.get({ location: { directory: process.cwd() } }, { signal }),
catch: (cause) => cause,
})
const page = yield* Effect.tryPromise({
try: (signal) =>
client.session.list(
{
project: location.project.id,
parentID: null,
order: "desc",
limit: Option.getOrElse(input.maxCount, () => 100),
},
{ signal },
),
catch: (cause) => cause,
})
if (input.format === "table" && page.data.length === 0) return
const output =
(input.format === "json"
? JSON.stringify(
page.data.map((session) => ({
id: session.id,
title: session.title,
updated: session.time.updated,
created: session.time.created,
projectId: session.projectID,
directory: session.location.directory,
})),
null,
2,
)
: formatTable(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
process.stdout.write(output, (error) => (error ? reject(error) : resolve()))
}),
)
if (!process.stdout.isTTY || Option.isSome(input.maxCount) || input.format === "json") {
yield* write
return
}
const { AppProcess } = yield* Effect.promise(() => import("@opencode-ai/util/process"))
const { LayerNode } = yield* Effect.promise(() => import("@opencode-ai/util/effect/layer-node"))
const { ChildProcess } = yield* Effect.promise(() => import("effect/unstable/process"))
yield* Effect.gen(function* () {
const processService = yield* AppProcess.Service
const pager = yield* processService
.spawn(
ChildProcess.make(
process.platform === "win32" ? "cmd" : "less",
process.platform === "win32" ? ["/c", "more"] : ["-R", "-S"],
{
stdin: Stream.make(new TextEncoder().encode(output)),
stdout: "inherit",
stderr: "inherit",
},
),
)
.pipe(Effect.option)
if (Option.isNone(pager)) {
yield* write
return
}
yield* pager.value.exitCode
}).pipe(Effect.provide(LayerNode.compile(AppProcess.node)))
})
export default Runtime.handler(Commands.commands.session.commands.list, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
}
+4
View File
@@ -53,6 +53,10 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
session: {
list: () => import("./commands/handlers/session/list"),
delete: () => import("./commands/handlers/session/delete"),
},
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
+25 -1
View File
@@ -17,6 +17,7 @@ import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Config } from "@opencode-ai/schema/config"
@@ -360,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -970,6 +979,20 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly providerContext?:
| {
readonly version: 1
readonly provenance: {
readonly providerID: Provider.ID
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: Schema.Json
}
| undefined
readonly text: string
readonly recent: string
}
@@ -1119,6 +1142,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,6 +68,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -594,6 +596,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -744,6 +757,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,6 +62,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -844,6 +846,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
+152 -46
View File
@@ -138,6 +138,23 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionProviderContextProvenance = {
providerID: string
provider: string
modelID: string
route: string
protocol: string
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -183,6 +200,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ModelCapabilities = {
tools: boolean
input: Array<string>
@@ -201,18 +220,6 @@ export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type FormWhen = {
key: string
op: "eq" | "neq"
@@ -512,19 +519,6 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -539,6 +533,8 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
id: string
sessionID: string
@@ -1345,23 +1341,6 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -1381,6 +1360,19 @@ export type ModelCompatibility = {
requireAssistantAfterTool?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1742,10 +1734,37 @@ export type SessionMessageToolStateError = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
providerContext?: SessionProviderContext
text: string
recent: string
}
}
export type SessionForked = {
id: string
@@ -1824,6 +1843,7 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -1999,6 +2019,7 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
canonical?: string
name?: string
env?: Array<string>
@@ -2008,6 +2029,7 @@ export type ConfigEntry =
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
modelID?: string
family?: string
name?: string
@@ -2085,6 +2107,11 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -2158,6 +2185,7 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3080,6 +3108,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3091,6 +3131,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3359,6 +3406,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3370,6 +3429,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3638,6 +3704,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3649,6 +3727,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4138,6 +4223,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
+12
View File
@@ -1032,6 +1032,18 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+1
View File
@@ -76,6 +76,7 @@ const layer = Layer.effect(
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
compaction: model.compaction ?? provider.compaction,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
+2 -5
View File
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission.js"
import type { LocationMutation } from "../../location-mutation.js"
import type { FileAccess } from "../../file-access.js"
import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js"
import { AbsolutePath } from "../../schema.js"
@@ -27,10 +27,7 @@ const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Info)
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
type PathAction = FileAccess.ExternalDirectoryAuthorization["action"] | typeof ReadTool.name | typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
@@ -57,6 +57,7 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -76,6 +77,7 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+173
View File
@@ -0,0 +1,173 @@
export * as FileAccess from "./file-access.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Array, Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
import type { SessionErrors } from "./session/error.js"
import type { Tool } from "./tool.js"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
readonly directory: AbsolutePath
readonly resource: string
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
readonly absolute: AbsolutePath
/** Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
export type Invocation = Pick<Tool.Context, "sessionID" | "agent" | "messageID" | "id">
export interface ReadOptions {
/** A target already authorized by this invocation, used for filename recovery. */
readonly siblingOf: Target
}
export interface Interface {
/** Resolve a lexical path and its permission resources, without requesting approval. */
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
/** Approve external directories in one batch, preserving first-seen resource order. */
readonly authorizeExternal: (
targets: readonly Target[],
context: Invocation,
metadata?: Permission.AssertInput["metadata"],
) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
/** Resolve a read target and obtain external-directory approval before read approval. */
readonly authorizeRead: (
file: string,
context: Invocation,
options?: ReadOptions,
) => Effect.Effect<Target, FSUtil.Error | Error | SessionErrors.NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileAccess") {}
/** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
normalized === "~"
? home
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
const slash = (value: string) => value.replaceAll("\\", "/")
const invocation = (context: Invocation) => ({
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool" as const, messageID: context.messageID, id: context.id },
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory,
resource: slash(path.join(directory, "*")),
save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")),
},
} satisfies Target
})
const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* (
targets: readonly Target[],
context: Invocation,
metadata?: Permission.AssertInput["metadata"],
) {
const external = Array.dedupeWith(
targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])),
(left, right) => left.resource === right.resource,
)
if (external.length === 0) return
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
...(metadata === undefined ? {} : { metadata }),
...invocation(context),
})
})
const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* (
file: string,
context: Invocation,
options?: ReadOptions,
) {
const target = yield* resolve({ path: file, kind: options ? "file" : undefined })
const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute)
// Filename recovery shares the directory approval, but checks the recovered file's own read rules.
if (!sibling) yield* authorizeExternal([target], context)
yield* permission.assert({
action: "read",
resources: [target.resource],
save: ["*"],
...invocation(context),
})
return target
})
return Service.of({ resolve, authorizeExternal, authorizeRead })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
+2 -4
View File
@@ -7,11 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment/index.js"
import type { Files } from "./environment/index.js"
import type { FileAccess } from "./file-access.js"
export interface Target {
readonly absolute: string
readonly resource: string
}
export type Target = Pick<FileAccess.Target, "absolute" | "resource">
export interface WriteInput {
readonly target: Target
+1
View File
@@ -42,6 +42,7 @@ export const layer = Layer.effect(
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
(error) => {
const mapped: Error = input.model
+75 -64
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -308,7 +309,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string> },
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
) {
const result = yield* proc
.run(
@@ -317,7 +318,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin },
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
)
.pipe(
Effect.mapError(
@@ -331,7 +332,8 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -385,9 +387,7 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,13 +464,7 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -499,19 +493,23 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -519,49 +517,57 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+2 -2
View File
@@ -17,7 +17,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { FileAccess } from "./file-access.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
@@ -82,7 +82,7 @@ const nodes = [
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
FileAccess.node,
FileMutation.node,
Formatter.node,
Mcp.node,
+3 -130
View File
@@ -1,130 +1,3 @@
export * as LocationMutation from "./location-mutation.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. A leading `~` expands to
* the home directory; other relative paths resolve from the active Location.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
readonly directory: string
/** `external_directory` permission resource. */
readonly resource: string
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
/** Absolute lexical path. */
readonly absolute: string
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
export interface Interface {
/**
* Resolve a path and derive its permission resources. A leading `~` expands
* to the home directory; other relative paths resolve from the Location.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
normalized === "~"
? home
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/")
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node],
})
/** @deprecated Use FileAccess for path resolution and authorization. */
export { FileAccess as LocationMutation } from "./file-access.js"
export * from "./file-access.js"
+39 -1
View File
@@ -1,6 +1,12 @@
export * as McpOAuth from "./oauth.js"
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
auth,
discoverOAuthServerInfo,
parseErrorResponse,
type OAuthClientProvider,
type OAuthServerInfo,
} from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import { Cause, Deferred, Effect } from "effect"
@@ -10,6 +16,12 @@ import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
/**
* opencode's OAuth Client ID Metadata Document. Authorization servers that support CIMD accept this URL as the
* client_id and fetch it to learn our name and redirect URIs, so no per-server dynamic registration is needed.
*/
export const CLIENT_METADATA_URL = "https://opencode.ai/oauth/opencode/client.json"
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
Effect.gen(function* () {
@@ -79,6 +91,10 @@ export interface Options {
readonly state?: string
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
readonly client?: { readonly id: string; readonly secret?: string }
/** Use opencode's Client ID Metadata Document as the client_id instead of registering dynamically. */
readonly clientMetadataUrl?: string
/** Pre-fetched authorization server discovery so the SDK does not repeat it. */
readonly discovery?: OAuthServerInfo
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
@@ -95,6 +111,8 @@ export const provider = (options: Options): OAuthClientProvider => {
const client = options.client
return {
redirectUrl: options.redirectUrl,
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(options.discovery ? { discoveryState: () => options.discovery } : {}),
clientMetadata: {
redirect_uris: [options.redirectUrl],
client_name: "opencode",
@@ -252,12 +270,32 @@ export const authorize = (input: {
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
// Discover the authorization server up front so we can decide how to identify ourselves. CIMD only works
// when the server advertises it, accepts public clients (our document declares no client secret), and the
// redirect is our own loopback URL (a user-configured redirect_uri is not in the published document).
// A configured client_id is pre-registered and always wins.
const discovery = yield* Effect.tryPromise({
try: () => discoverOAuthServerInfo(input.config.url, { fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
!oauth?.client_id &&
!oauth?.redirect_uri &&
discovery.authorizationServerMetadata?.client_id_metadata_document_supported === true &&
(discovery.authorizationServerMetadata.token_endpoint_auth_methods_supported?.includes("none") ?? false)
yield* Effect.logInfo("mcp oauth client registration selected", {
...fields,
registration: oauth?.client_id ? "static" : cimd ? "cimd" : "dcr",
})
let authorizationUrl: URL | undefined
const oauthProvider = provider({
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
scope: oauth?.scope,
state,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
clientMetadataUrl: cimd ? CLIENT_METADATA_URL : undefined,
discovery,
onRedirect: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
+29 -1
View File
@@ -52,10 +52,24 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError<Unresol
}
}
export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCompactionError>()(
"SessionRunnerModel.UnsupportedCompactionError",
{
providerID: Provider.ID,
modelID: ID,
route: Schema.String,
},
) {
override get message() {
return `Provider compaction is not supported by ${this.providerID}/${this.modelID} (${this.route})`
}
}
export type Error =
| VariantUnavailableError
| UnsupportedPackageError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
| Integration.AuthorizationError
export interface Resolved {
@@ -69,6 +83,8 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
}
export interface Interface {
@@ -115,9 +131,20 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
): Effect.Effect<
LanguageModel,
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
)
}),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
@@ -296,6 +323,7 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
}
})
return Service.of({
+3 -3
View File
@@ -31,6 +31,7 @@ import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileAccess } from "../file-access.js"
import { FileMutation } from "../file-mutation.js"
import { Formatter } from "../formatter.js"
import { Form } from "../form.js"
@@ -44,7 +45,6 @@ import { Integration } from "../integration.js"
import { Job } from "../job.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
@@ -103,6 +103,7 @@ const services = [
Credential.Service,
Bus.Service,
Environment.Service,
FileAccess.Service,
FileMutation.Service,
Formatter.Service,
LocationWatcherPolicy.Service,
@@ -116,7 +117,6 @@ const services = [
Job.Service,
KV.Service,
Location.Service,
LocationMutation.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
@@ -152,6 +152,7 @@ export const requirements = LayerNode.group([
Credential.node,
Bus.node,
Environment.node,
FileAccess.node,
FileMutation.node,
Formatter.node,
LocationWatcherPolicy.node,
@@ -165,7 +166,6 @@ export const requirements = LayerNode.group([
Job.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Mcp.node,
Npm.node,
@@ -263,6 +263,7 @@ export const OpenAIPlugin = define({
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
})
for (const model of item.models.values()) {
+24
View File
@@ -57,8 +57,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { EventLog } from "@opencode-ai/schema/event-log"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -113,6 +116,7 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -142,6 +146,13 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -230,6 +241,7 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -362,6 +374,17 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -450,6 +473,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+231 -83
View File
@@ -15,12 +15,15 @@ import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import { SessionHistory } from "./history.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionProviderContext } from "./provider-context.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionRunnerRetry } from "./runner/retry.js"
import { SessionSchema } from "./schema.js"
@@ -93,6 +96,8 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
/** Known overflow must recover from durable history, not submit the overflowing native window again. */
readonly overflow?: boolean
}
type RequiredInput = {
@@ -124,7 +129,10 @@ type ExecuteInput = AutoInput & {
}
export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| (Pick<SessionMessage.CompactionCompleted, "status"> & {
/** Consumes the logical step's one overflow rebuild even when the native attempt overflowed first. */
readonly recoveredOverflow?: boolean
})
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface extends State.Transformable<Editor> {
@@ -136,14 +144,14 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
const hasInputUsage = (message: SessionMessage.Info) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0
export const estimateTokens = (input: RequiredInput) => {
const index = input.messages.findLastIndex(
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
)
const index = input.messages.findLastIndex(hasInputUsage)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
@@ -199,6 +207,32 @@ const estimatePart = (part: ContentPart): number => {
)
}
/** Keep whole, real user messages, never synthetic guidance or half an attachment/tool exchange. */
export const retainUsers = (
messages: readonly SessionMessage.Info[],
model: Pick<SessionRunnerModel.Resolved, "ref" | "capabilities">,
keepTokens: number,
) => {
const users = SessionModelRequest.boundImages(
SessionModelRequest.unsupportedParts(
toLLMMessages(
messages.filter((message) => message.type === "user").map((message) => ({ ...message, skills: undefined })),
model.ref,
),
model.capabilities,
),
)
let tokens = 0
let start = users.length
for (let index = users.length - 1; index >= 0; index--) {
const size = users[index].content.reduce((sum, part) => sum + estimatePart(part), 0)
if (tokens + size > keepTokens) break
tokens += size
start = index
}
return users.slice(start)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
@@ -336,6 +370,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const db = (yield* Database.Service).db
const state = State.create<Settings, Editor>({
name: "session-compaction",
@@ -357,6 +392,154 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const started = (input: ExecuteInput, recent: string) =>
input.started
? Effect.void
: bus.publish(SessionEvent.Compaction.Started, {
sessionID: input.context.session.id,
reason: input.reason,
recent,
inputID: input.inputID,
})
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
const interrupted = (input: ExecuteInput) =>
input.reason === "auto"
? failed({
sessionID: input.context.session.id,
reason: input.reason,
inputID: input.inputID,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}).pipe(Effect.asVoid)
: Effect.void
const compactionRequest = (
input: ExecuteInput,
messages: readonly SessionMessage.Info[],
prompt: Message[],
webSocket?: "session",
) => {
const context = input.context
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages,
})
return input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
...prompt,
],
},
webSocket,
})
}
/** The durable transcript since the last local summary, re-expanding every native window. */
const original = (sessionID: SessionSchema.ID) => SessionHistory.load(db, sessionID, "local").pipe(Effect.orDie)
const recoverLocally = (input: ExecuteInput) =>
original(input.context.session.id).pipe(
Effect.flatMap((messages) => execute({ ...input, context: { ...input.context, messages } })),
)
const executeProvider = Effect.fn("SessionCompaction.executeProvider")(function* (input: ExecuteInput) {
const context = input.context
const reject = (message: string) =>
failed({
sessionID: context.session.id,
reason: input.reason,
inputID: input.inputID,
error: { type: "provider.unsupported-operation", message },
})
const prepared = yield* compactionRequest(input, context.messages, [], "session")
const request = prepared.request
const provenance = SessionProviderContext.provenance(context.model)
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
// History is selected before request hooks. Until that interface can select on the final route,
// require routing in the catalog; never install a checkpoint that the next request would skip.
if (
!SessionProviderContext.compatible(
provenance,
SessionProviderContext.provenance({ model: request.model, ref: context.model.ref }),
)
)
return yield* reject(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
})
yield* started(input, "")
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
// Transient provider failures retry like any other request; only a known automatic overflow permits
// local recovery, and nothing is installed until the provider returns a checkpoint.
const result = yield* restore(
Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
const result = yield* llm
.compact(request, { ...prepared.options, mechanism: "trigger" })
.pipe(transient)
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
}
if (LLMClient.canCompact(request))
return yield* llm
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
.pipe(transient)
// Model resolution admits provider policies only for routes with a compaction operation.
return yield* Effect.die(
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
)
}),
)
if (result.usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
sessionID: context.session.id,
source: "compaction" as const,
...SessionUsage.record(result.usage, context.model.cost),
})
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
text: "",
recent: "",
providerContext: SessionProviderContext.encode(provenance, result.replacement),
})
return { status: "completed" as const }
}),
).pipe(
Effect.onInterrupt(() => interrupted(input)),
Effect.catchTag(
"AI.Error",
(cause): Effect.Effect<Outcome> =>
input.reason === "auto" && isContextOverflowFailure(cause)
? recoverLocally({ ...input, started: true }).pipe(
Effect.map((result) =>
result.status === "completed" ? { ...result, recoveredOverflow: true } : result,
),
)
: failed({
sessionID: context.session.id,
reason: input.reason,
inputID: input.inputID,
error: toSessionError(cause),
}),
),
)
})
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
const context = input.context
const history = splitHistory(context.messages, state.get().tokens)
@@ -367,13 +550,7 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
if (!input.started)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: context.session.id,
reason: input.reason,
recent: history.recent,
inputID: input.inputID,
})
yield* started(input, history.recent)
const chunks: string[] = []
let failure: SessionError.Error | undefined
@@ -388,37 +565,19 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages: history.messages,
})
const prepared = yield* input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
],
},
})
const retry = yield* SessionRunnerRetry.policy(context.session.id)
const prepared = yield* compactionRequest(input, history.messages, [
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
])
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
})
for (const request of [
prepared.request,
LLMRequest.update(prepared.request, {
@@ -479,42 +638,13 @@ export const layer = Layer.effect(
}
return Effect.void
}),
Effect.retry({
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* retry({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
retry: SessionRunnerRetry.isRetryable(cause),
})
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
}),
transient,
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
input.reason === "auto"
? failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
@@ -544,13 +674,24 @@ export const layer = Layer.effect(
})
return { status: "completed" as const }
})
const compact = (input: AutoInput) => execute({ ...input, reason: "auto" })
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
// Native usage describes the compaction operation, not the replacement's size. Wait for
// a primary response to anchor the new window, including after restart or new admission.
if (
input.messages.findLastIndex(hasInputUsage) < input.messages.findLastIndex(SessionProviderContext.isCheckpoint)
)
return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
@@ -559,7 +700,12 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
return estimateTokens(input) >= promptCeiling
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -578,15 +724,17 @@ export const layer = Layer.effect(
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (context) =>
execute({
onSuccess: (context) => {
const request = {
context,
instructionUpdate: context.instructionUpdate,
prepare: input.prepare,
reason: "manual",
reason: "manual" as const,
inputID: input.inputID,
started: input.started,
}),
}
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
},
}),
)
})
@@ -604,5 +752,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient],
deps: [Bus.node, Database.node, llmClient],
})
+7 -1
View File
@@ -18,6 +18,7 @@ import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
import { InstructionEntry } from "./instruction-entry.js"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
@@ -156,7 +157,12 @@ const layer = Layer.effect(
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
const history = yield* SessionHistory.entriesForRunner(
db,
selection.session.id,
selection.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session: selection.session,
agent: selection.agent,
+138
View File
@@ -0,0 +1,138 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode-ai/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+7 -1
View File
@@ -9,6 +9,7 @@ import type { Instructions } from "../instructions/index.js"
import { SessionContext } from "./context.js"
import type { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import type { SessionSchema } from "./schema.js"
@@ -29,7 +30,12 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
const context = yield* SessionContext.Service
const selection = yield* context.select(input.session.id)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const history = yield* SessionHistory.preview(
database.db,
selection.session.id,
selection.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
model,
+63 -9
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -6,13 +6,30 @@ import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { Instructions } from "../instructions/index.js"
import { InstructionState } from "./instruction-state.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionMessageTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
/**
* Which completed compactions bound a history read. Local summaries always do. Native
* windows do for model-neutral readers (`latest`), never for the original transcript
* (`local`), and only when the target model can replay them (a provenance).
*/
export type Boundary = "latest" | "local" | SessionProviderContext.Provenance
const replayable = (message: SessionMessage.Info, boundary: Boundary) =>
!SessionProviderContext.isCheckpoint(message) ||
boundary === "latest" ||
(boundary !== "local" && SessionProviderContext.compatible(message.providerContext.provenance, boundary))
export const latestCompaction = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -21,6 +38,19 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
boundary === "latest"
? undefined
: or(
sql`json_extract(${SessionMessageTable.data}, '$.providerContext') is null`,
boundary === "local"
? undefined
: and(
...Object.entries(boundary).map(
([key, value]) =>
sql`json_extract(${SessionMessageTable.data}, ${`$.providerContext.provenance.${key}`}) = ${value}`,
),
),
),
),
)
.orderBy(desc(SessionMessageTable.seq))
@@ -31,6 +61,11 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
: Effect.void,
),
Effect.mapError(
() =>
new MessageDecodeError({
@@ -40,8 +75,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
),
)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const messageEntries = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
const compaction = yield* latestCompaction(db, sessionID, boundary)
const rows = yield* db
.select()
.from(SessionMessageTable)
@@ -54,24 +93,38 @@ const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, session
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
const entries = yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
// Re-expansion may cross a native checkpoint whose completion already advanced the instruction
// epoch: the baseline supersedes the chronological updates before it. Forks seed their baseline
// at sequence 0 but retain parent sequences, so the copied checkpoint still retires them.
const native = entries.findLast((entry) => SessionProviderContext.isCheckpoint(entry.message))
// Skipped native checkpoints are not textual summaries. Their original transcript remains available.
return entries.filter(
(entry) =>
!(entry.message.type === "system" && native && entry.seq < native.seq) && replayable(entry.message, boundary),
)
})
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
export const load = Effect.fn("SessionHistory.load")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
return (yield* messageEntries(db, sessionID, boundary)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.List,
boundary: Boundary,
) {
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
const messages = yield* messageEntries(db, sessionID, boundary)
return {
initial: yield* InstructionState.initial(db, sessionID, instructions),
entries: messages,
@@ -85,12 +138,13 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.List,
boundary: Boundary,
) {
const observed = yield* Instructions.read(instructions)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
const messages = yield* messageEntries(db, sessionID, boundary)
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
const unsettled = messages.findIndex(
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
+22 -3
View File
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -123,9 +138,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -413,6 +430,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
})
return
@@ -427,6 +445,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
time: { created },
}),
@@ -15,6 +15,7 @@ import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionSystemPrompt } from "./system-prompt.js"
@@ -345,6 +346,21 @@ export const layer = Layer.effect(
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
}),
)
// History selects native windows against the catalog route before hooks run. A newly installed
// routing hook must not send an existing opaque window to another deployment; `prepare` has no
// error channel, so like hook failures this surfaces as a defect.
const selected = SessionProviderContext.provenance(resolved)
if (
selected &&
!SessionProviderContext.compatible(
selected,
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
) &&
request.messages.some((message) => message.content.some((part) => part.type === "compaction"))
)
return yield* Effect.die(
new Error("Provider context is incompatible with the route selected by model request hooks"),
)
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
@@ -0,0 +1,69 @@
export * as SessionProviderContext from "./provider-context.js"
import { Message } from "@opencode-ai/ai"
import { SessionProviderContext } from "@opencode-ai/schema/session-provider-context"
import { Schema } from "effect"
import { isDeepStrictEqual } from "node:util"
import { Hash } from "@opencode-ai/util/hash"
import type { SessionMessage } from "./message.js"
import type { SessionRunnerModel } from "./runner/model.js"
export type Provenance = SessionProviderContext.Provenance
export const Info = SessionProviderContext.Info
export type Info = SessionProviderContext.Info
const messages = Schema.toCodecJson(Schema.Array(Message))
/** No guessed endpoints. Dynamic URL builders cannot establish a durable deployment identity here. */
export function provenance(resolved: Pick<SessionRunnerModel.Resolved, "model" | "ref">): Provenance | undefined {
const model = resolved.model
const endpoint = model.route.endpoint
if (!endpoint.baseURL || typeof endpoint.path !== "string") return undefined
return {
providerID: resolved.ref.providerID,
provider: model.provider,
modelID: model.id,
route: model.route.id,
protocol: model.route.protocol,
endpoint: Hash.sha256(
JSON.stringify([
endpoint.baseURL,
endpoint.path,
Object.entries(endpoint.query ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
]),
),
}
}
export const compatible = (source: Provenance, target: Provenance | undefined) =>
target !== undefined && isDeepStrictEqual(source, target)
/** A completed compaction that installed a native replacement window instead of a local summary. */
export const isCheckpoint = (
message: SessionMessage.Info,
): message is SessionMessage.CompactionCompleted & { readonly providerContext: Info } =>
message.type === "compaction" && message.status === "completed" && message.providerContext !== undefined
/** Stores the canonical replacement, not a local summary or transport continuation.
* Provider and attachment metadata can contain optional undefined entries. Use JSON's
* omission semantics, while preserving canonical binary media as equivalent base64.
*/
export const encode = (provenance: Provenance, replacement: ReadonlyArray<Message>): Info => ({
version: 1,
provenance,
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(
JSON.stringify(
replacement.map((message) => ({
...message,
content: message.content.map((part) =>
part.type === "media" && part.data instanceof Uint8Array
? { ...part, data: Buffer.from(part.data).toString("base64") }
: part,
),
})),
),
),
})
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
+13 -4
View File
@@ -11,6 +11,7 @@ import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionHistory } from "../history.js"
import { SessionProviderContext } from "../provider-context.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
@@ -113,7 +114,12 @@ const layer = Layer.effect(
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)
const history = yield* SessionHistory.preview(
db,
session.id,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session: selected.session,
agent: selected.agent,
@@ -206,8 +212,9 @@ const layer = Layer.effect(
prepare: context.prepare,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
const result = yield* compaction.compact(compactionInput)
if (result.status !== "completed") return yield* new StepFailedError({ error: result.error })
if (result.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
continue
}
@@ -250,7 +257,9 @@ const layer = Layer.effect(
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
? compaction
.compact({ ...compactionInput, overflow: true })
.pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
@@ -35,6 +35,8 @@ export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
@@ -57,6 +59,7 @@ export const resolved = (
readonly variant?: Model.VariantID
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Provider.Compaction
},
): Resolved => ({
model,
@@ -68,6 +71,7 @@ export const resolved = (
capabilities: options.capabilities,
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
})
const layer = Layer.effect(
+20 -1
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { AIError, isContextOverflowFailure } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -10,6 +10,7 @@ import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { toSessionError } from "../to-session-error.js"
interface Input {
readonly cause: AIError
@@ -106,6 +107,24 @@ export const policy = (sessionID: SessionSchema.ID) =>
})
})
/**
* Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the
* session retry hook adjust each decision. Context overflow is never transient: callers recover it.
*/
export const transient =
(decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) =>
<A, R>(effect: Effect.Effect<A, AIError, R>) =>
Effect.retry(effect, {
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) })
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
})
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const decide = yield* policy(sessionID)
@@ -3,6 +3,7 @@ import type { Model } from "@opencode-ai/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import { SessionMessage } from "../message.js"
import { SessionProviderContext } from "../provider-context.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
@@ -225,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
@@ -274,6 +276,9 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
return assistant(message, model, providerMetadataKey)
case "compaction":
if (message.status !== "completed") return []
// History selection only keeps native windows the target model can replay.
if (SessionProviderContext.isCheckpoint(message))
return [...SessionProviderContext.decode(message.providerContext)]
return [
Message.make({
id: message.id,
+1 -1
View File
@@ -172,7 +172,7 @@ const layer = Layer.effect(
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID, "latest")),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()
@@ -46,6 +46,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
return { type: "provider.unsupported-operation", message: cause.message }
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
+33 -16
View File
@@ -131,38 +131,55 @@ const layer = Layer.effect(
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
return {
source: repo.source,
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
return yield* git.tree
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
.diff({
...comparison.input,
repository: compared.repository,
from: compared.from,
to: compared.to,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: input.paths,
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+5 -13
View File
@@ -15,7 +15,7 @@ import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -109,7 +109,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -143,16 +143,8 @@ export const Plugin = {
})
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const target = yield* access.resolve({ path: input.path, kind: "file" })
yield* access.authorizeExternal([target], context)
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
@@ -218,7 +210,7 @@ export const Plugin = {
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
fileMutation.withLock([FileAccess.resolvePath(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+4 -11
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
import { Permission } from "../../permission.js"
@@ -48,7 +48,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -63,15 +63,8 @@ export const Plugin = {
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* access.resolve({ path: searchPath ?? ".", kind: "directory" })
yield* access.authorizeExternal([target], context)
yield* permission.assert({
action: name,
resources: [input.pattern],
+4 -10
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
@@ -67,7 +67,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -82,14 +82,8 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* access.resolve({ path: input.path ?? "." })
yield* access.authorizeExternal([target], context)
yield* permission.assert({
action: name,
resources: [input.pattern],
+12 -18
View File
@@ -9,7 +9,7 @@ import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
import { FileMutation } from "../../file-mutation.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission.js"
import DESCRIPTION from "../patch.txt"
@@ -45,29 +45,29 @@ export const toModelContent = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: LocationMutation.Target
readonly target: FileAccess.Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: LocationMutation.Target
readonly target: FileAccess.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly target: FileAccess.Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: LocationMutation.Target
readonly moveTarget?: FileAccess.Target
})
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const fileMutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
@@ -86,9 +86,9 @@ export const Plugin = {
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
LocationMutation.resolvePath(location.directory, hunk.path),
FileAccess.resolvePath(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
? [FileAccess.resolvePath(location.directory, hunk.movePath)]
: []),
])
: []
@@ -114,17 +114,11 @@ export const Plugin = {
const prepared: Prepared[] = []
const updates = new Map<string, string>()
const resolveTarget = Effect.fnUntraced(function* (value: string) {
const target = yield* mutation.resolve({ path: value, kind: "file" })
const target = yield* access.resolve({ path: value, kind: "file" })
if (!target.externalDirectory) return target
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
yield* access.authorizeExternal([target], context, {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
})
return target
})
+6 -34
View File
@@ -6,8 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { FileAccess } from "../../file-access.js"
import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js"
import { ReadToolFileSystem } from "../read-filesystem.js"
@@ -31,8 +30,7 @@ export const Plugin = {
id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
const access = yield* FileAccess.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
@@ -48,37 +46,13 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
Effect.gen(function* () {
if (target.externalDirectory && authorizeExternal)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
})
const read = (target: LocationMutation.Target) =>
reader.read(AbsolutePath.make(target.absolute), target.resource, {
const read = (target: FileAccess.Target) =>
reader.read(target.absolute, target.resource, {
offset: input.offset,
limit: input.limit,
})
const requested = yield* mutation.resolve({ path: input.path })
yield* authorize(requested)
const requested = yield* access.authorizeRead(input.path, context)
const result = yield* read(requested).pipe(
Effect.map((content) => ({ content, target: requested, path: input.path })),
Effect.catchIf(
@@ -89,9 +63,7 @@ export const Plugin = {
Effect.orElseSucceed(() => undefined),
)
if (!alternate) return yield* missing(input.path, requested.absolute)
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
// The candidate is a sibling under the external directory already approved above.
yield* authorize(target, false)
const target = yield* access.authorizeRead(alternate, context, { siblingOf: requested })
const content = yield* read(target).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
+6 -18
View File
@@ -8,7 +8,7 @@ import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
import { Job } from "../../job.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { NonNegativeInt } from "../../schema.js"
import { Session } from "../../session.js"
@@ -104,7 +104,7 @@ export const Plugin = {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const compatibleShell = shellSelect.resolve({ priority: "compat" })
@@ -117,30 +117,18 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" })
invocation.cwd = target.absolute
const timeout = invocation.timeout
const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
access.resolve({
path: FileAccess.resolvePath(target.absolute, directory),
kind: "directory",
}),
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* access.authorizeExternal([target, ...directories], context)
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
+4 -11
View File
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -46,7 +46,7 @@ export const toModelContent = (output: Output) =>
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -68,15 +68,8 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* access.resolve({ path: input.path, kind: "file" })
yield* access.authorizeExternal([target], context)
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
)
+1 -1
View File
@@ -25,7 +25,7 @@ const InputObject = Schema.StructWithRest(
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
webfetch: Schema.optional(Rule),
websearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
+3
View File
@@ -1342,6 +1342,7 @@ describe("Config", () => {
bash: "ask",
edit: { "*.md": "allow", "*": "deny" },
question: "deny",
webfetch: { "*": "ask", "https://en.wikipedia.org/*": "allow" },
},
agent: {
reviewer: {
@@ -1420,6 +1421,8 @@ describe("Config", () => {
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "https://en.wikipedia.org/*", effect: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toMatchObject({
system: "Review changes.",
@@ -32,6 +32,54 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "@opencode-ai/ai/providers/openai/responses",
compaction: { mode: "provider", threshold: 120_000 },
models: {
native: {},
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode-ai/ai/providers/openai/chat" },
unsupported: { package: "@opencode-ai/ai/providers/openai/chat" },
},
},
default: { package: "@opencode-ai/ai/providers/openai/chat", models: { chat: {} } },
},
}),
}),
])
const native = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("native")))
const local = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
expect(yield* ModelResolver.fromCatalogModel(unsupported).pipe(Effect.flip)).toMatchObject({
_tag: "SessionRunnerModel.UnsupportedCompactionError",
message: "Provider compaction is not supported by custom/unsupported (openai-chat)",
})
}),
)
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -4,17 +4,20 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped, withTempDir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, {
LayerNode.compile(FileAccess.node, {
replacements: [
Permission.node.replace(permissionLayer()),
Location.node.replace(
Layer.succeed(
Location.Service,
@@ -31,21 +34,14 @@ function provide(directory: string, projectDirectory = directory) {
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationMutation", () => {
describe("FileAccess.resolve", () => {
it.live("resolves an active relative existing file target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -57,11 +53,11 @@ describe("LocationMutation", () => {
)
it.live("resolves an active relative prospective file target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -71,10 +67,10 @@ describe("LocationMutation", () => {
)
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -89,11 +85,12 @@ describe("LocationMutation", () => {
)
it.live("allows a relative path outside the Location but inside the project worktree", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const active = path.join(directory, "packages", "opencode")
yield* Effect.promise(() => fs.mkdir(active, { recursive: true }))
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../../README.md" })
expect(target).toMatchObject({
absolute: path.join(directory, "README.md"),
resource: "../../README.md",
@@ -104,37 +101,34 @@ describe("LocationMutation", () => {
)
it.live("does not treat a filesystem-root project sentinel as an internal boundary", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../outside.txt" })
expect(target.externalDirectory).toBeDefined()
}).pipe(provide(directory, path.parse(directory).root)),
),
)
it.live("resolves a prospective target below an external symlink lexically", () =>
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
const outside = yield* tmpdirScoped()
yield* Effect.promise(() => fs.symlink(outside.path, path.join(directory, "escape")))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
})
expect(target.externalDirectory).toBeUndefined()
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory))
}),
}).pipe(provide(directory)),
),
)
it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
@@ -142,8 +136,8 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
const access = yield* FileAccess.Service
expect(yield* access.resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -152,11 +146,11 @@ describe("LocationMutation", () => {
)
it.live("accepts an explicit absolute in-location target without external approval", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -167,12 +161,12 @@ describe("LocationMutation", () => {
)
it.live("requires external-directory authorization for an explicit external absolute target", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -188,26 +182,26 @@ describe("LocationMutation", () => {
)
it.live("resolves an existing external file target", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
expect(target.externalDirectory?.directory).toBe(AbsolutePath.make(outside))
}).pipe(provide(directory)),
),
),
)
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -218,12 +212,12 @@ describe("LocationMutation", () => {
)
it.live("authorizes prospective external descendants at their lexical parent", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -234,19 +228,18 @@ describe("LocationMutation", () => {
),
)
test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
test("ignores unknown path input fields", () => {
expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
})
})
test("expands a leading tilde against the home directory", () => {
const home = path.resolve("/Users/aiden")
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
expect(FileAccess.resolvePath("/project", "~", home)).toBe(home)
expect(FileAccess.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(FileAccess.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(FileAccess.resolvePath("/project", "~\\notes.md", home)).toBe(
process.platform === "win32" ? path.resolve(home, "notes.md") : path.resolve("/project", "~\\notes.md"),
)
})
@@ -257,16 +250,16 @@ describe("LocationMutation", () => {
["/cygdrive/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
["/mnt/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
])("normalizes Windows shell drive path %s before resolution", (input, windows) => {
expect(LocationMutation.resolvePath("/project", input)).toBe(
expect(FileAccess.resolvePath("/project", input)).toBe(
process.platform === "win32" ? path.resolve(windows) : path.resolve(input),
)
})
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -282,8 +275,8 @@ describe("LocationMutation", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempLocationLayer } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { toolIdentity } from "./lib/tool"
const invocation = {
...toolIdentity,
sessionID: Session.ID.make("ses_file_access"),
id: Tool.CallID.make("call-read"),
}
const slash = (file: string) => file.replaceAll("\\", "/")
function provide(requests: Permission.AssertInput[], denied?: string) {
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [
Location.node.replace(tempLocationLayer),
Permission.node.replace(
permissionLayer({
assert: (input) =>
Effect.gen(function* () {
requests.push(input)
if (input.action === denied)
yield* new Permission.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
})
}),
}),
),
]),
)
}
describe("FileAccess.authorizeRead", () => {
it.live("returns an absolute target and preserves invocation identity on the read assertion", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const location = yield* Location.Service
const target = yield* access.authorizeRead("src/../README.md", invocation)
const absolute: AbsolutePath = target.absolute
expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md")))
expect(target.externalDirectory).toBeUndefined()
expect(requests).toEqual([
{
action: "read",
resources: ["README.md"],
save: ["*"],
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
},
])
}).pipe(provide(requests))
})
it.live("authorizes an external directory before the file's read rules", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.authorizeRead("../notes.txt", invocation)
expect(requests).toMatchObject([
{ action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] },
{ action: "read", resources: [slash(target.absolute)] },
])
for (const request of requests) {
expect(request).toMatchObject({
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
})
}
}).pipe(provide(requests))
})
for (const action of ["external_directory", "read"]) {
it.live(`propagates ${action} denial without continuing authorization`, () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip)
expect(error).toBeInstanceOf(Permission.BlockedError)
expect(requests.map((request) => request.action)).toEqual(
action === "external_directory" ? ["external_directory"] : ["external_directory", "read"],
)
}).pipe(provide(requests, action))
})
}
it.live("reuses a sibling's directory approval only for the supplied recovery call", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const requested = yield* access.authorizeRead("../report final.txt", invocation)
const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested })
yield* access.authorizeRead("../notes.txt", invocation)
expect(requests.map((request) => request.action)).toEqual([
"external_directory",
"read",
"read",
"external_directory",
"read",
])
expect(requests[2].resources).toEqual([slash(recovered.absolute)])
}).pipe(provide(requests))
})
it.live("checks the external directory for a target that is not a sibling", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const requested = yield* access.authorizeRead("README.md", invocation)
yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested })
expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"])
}).pipe(provide(requests))
})
it.live("batches external resources in first-seen order and preserves broader repository saves", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const external = yield* tmpdirScoped()
const git = path.join(external.path, "git")
const hg = path.join(external.path, "hg")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(git, ".git"), { recursive: true })
await fs.mkdir(path.join(git, "nested"))
await fs.mkdir(path.join(hg, ".hg"), { recursive: true })
await fs.mkdir(path.join(hg, "nested"))
})
const access = yield* FileAccess.Service
const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" })
const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" })
const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" })
const internal = yield* access.resolve({ path: "README.md" })
const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) }
yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata)
expect(requests).toEqual([
{
action: "external_directory",
resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))],
save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))],
metadata,
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
},
])
yield* access.authorizeExternal([internal], invocation)
expect(requests).toHaveLength(1)
yield* access.authorizeExternal([second], invocation)
expect(requests).toHaveLength(2)
expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))])
expect(Object.hasOwn(requests[1], "metadata")).toBe(false)
}).pipe(provide(requests))
})
})
+30 -30
View File
@@ -7,12 +7,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { withTempDir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
const activeLocation = Layer.succeed(
@@ -20,27 +22,22 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
AppNodeBuilder.build(LayerNode.group([FileAccess.node, FileMutation.node]), [
Location.node.replace(activeLocation),
Permission.node.replace(permissionLayer()),
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
]),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("FileMutation", () => {
it.live("writes an existing internal file and returns a stable result", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "hello.txt" })
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write",
@@ -54,9 +51,10 @@ describe("FileMutation", () => {
)
it.live("writes a prospective internal file and creates parent directories", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({
const access = yield* FileAccess.Service
const target = yield* access.resolve({
path: path.join("src", "nested", "hello.txt"),
})
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
@@ -73,12 +71,13 @@ describe("FileMutation", () => {
)
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const preservedPath = path.join(directory, "preserved.txt")
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const access = yield* FileAccess.Service
const preserved = yield* access.resolve({ path: "preserved.txt" })
const created = yield* access.resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
@@ -91,11 +90,12 @@ describe("FileMutation", () => {
)
it.live("writes an explicitly resolved external target", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
expect(result).toEqual({
@@ -111,7 +111,7 @@ describe("FileMutation", () => {
)
it.live("serializes concurrent writes to the same absolute target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
@@ -133,10 +133,10 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
const firstPlan = yield* access.resolve({ path: "shared.txt" })
const secondPlan = yield* access.resolve({ path: "shared.txt" })
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
@@ -154,7 +154,7 @@ describe("FileMutation", () => {
)
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -183,7 +183,7 @@ describe("FileMutation", () => {
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -205,7 +205,7 @@ describe("FileMutation", () => {
)
it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -222,10 +222,10 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
const firstPlan = yield* access.resolve({ path: "first.txt" })
const secondPlan = yield* access.resolve({ path: "second.txt" })
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
+37
View File
@@ -6,6 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { VcsPatch } from "@opencode-ai/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -196,6 +197,42 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+85
View File
@@ -164,4 +164,89 @@ describe("MCP OAuth", () => {
test("rejects an invalid redirect URL", async () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
describe("client registration", () => {
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
const authorizationServer = (metadata: Record<string, unknown>) => {
const registrations: unknown[] = []
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
registration_endpoint: `${url.origin}/register`,
response_types_supported: ["code"],
...metadata,
})
if (request.method === "POST" && url.pathname === "/register") {
registrations.push(await request.json())
return Response.json({ client_id: "registered", redirect_uris: [] })
}
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "access", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
return { server, registrations, tokenRequests }
}
const start = (server: ReturnType<typeof Bun.serve>, oauth?: ConfigMCP.OAuth) =>
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({ type: "remote", url: server.url.href, ...(oauth ? { oauth } : {}) }),
methodID: Integration.MethodID.make("oauth"),
})
return { authorization, url: new URL(authorization.url) }
})
const cimd = { client_id_metadata_document_supported: true, token_endpoint_auth_methods_supported: ["none"] }
test("uses the client metadata document when the server supports public CIMD clients", async () => {
const { server, registrations, tokenRequests } = authorizationServer(cimd)
const credential = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { authorization, url } = yield* start(server)
expect(url.searchParams.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
const redirect = new URL(url.searchParams.get("redirect_uri")!)
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", url.searchParams.get("state")!)
yield* Effect.promise(() => fetch(redirect))
return yield* authorization.callback
}),
),
).finally(() => server.stop(true))
expect(registrations).toHaveLength(0)
expect(tokenRequests[0]?.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
expect(McpOAuth.clientFromCredential(credential)).toEqual({ client_id: McpOAuth.CLIENT_METADATA_URL })
})
test("registers dynamically when the server does not accept public clients", async () => {
const { server, registrations } = authorizationServer({
client_id_metadata_document_supported: true,
token_endpoint_auth_methods_supported: ["client_secret_post"],
})
const { url } = await Effect.runPromise(Effect.scoped(start(server))).finally(() => server.stop(true))
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(1)
})
test("registers dynamically when a custom redirect_uri is configured", async () => {
const { server, registrations } = authorizationServer(cimd)
const { url } = await Effect.runPromise(
Effect.scoped(start(server, { redirect_uri: "http://127.0.0.1:0/custom" })),
).finally(() => server.stop(true))
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(1)
})
})
})
@@ -145,7 +145,11 @@ describe("OpenAIPlugin", () => {
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(provider.headers).toMatchObject({
originator: "opencode",
"chatgpt-account-id": "acct_123",
"x-codex-beta-features": "remote_compaction_v2",
})
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.hasHttpHooks).toBe(false)
@@ -206,6 +210,8 @@ describe("OpenAIPlugin", () => {
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.capabilities.responsesWebsockets).toBe(true)
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
@@ -197,6 +197,20 @@ it.effect("auto compaction estimates current content against the buffered prompt
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)
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
+198
View File
@@ -0,0 +1,198 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionDiff } from "@opencode-ai/core/session/diff"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ messageID: steer })).toEqual(busy)
expect(yield* diff({ messageID: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ messageID: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "messageID",
})
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+3 -2
View File
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
@@ -42,7 +42,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
LocationMutation.node,
FileAccess.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -64,7 +64,7 @@ const testLayer = AppNodeBuilder.build(
Session.node,
Location.node,
FSUtil.node,
LocationMutation.node,
FileAccess.node,
ReadToolFileSystem.node,
readToolNode,
Tool.node,
@@ -0,0 +1,437 @@
import { expect, test } from "bun:test"
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionHistory } from "@opencode-ai/core/session/history"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionInbox.node,
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
PluginHooks.node,
llmClient,
]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)
const setup = Effect.fnUntraced(function* (endpoint = false) {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
const store = yield* SessionStore.Service
const compaction = yield* SessionCompaction.Service
const requests = yield* SessionModelRequest.Service
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 bodies: Record<string, unknown>[] = []
const headers: Headers[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
state.calls++
headers.push(request.headers)
bodies.push(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)))(
await request.text(),
),
)
if (state.hang) {
Deferred.doneUnsafe(blocked, Effect.void)
return hanging.promise
}
// Persistent failures opt out of retries so the schedule's backoff stays out of these tests.
if (state.failure || state.flaky) {
const retry = state.flaky
state.flaky = false
return Response.json(
{ error: { message: "fixture rate limit", type: "rate_limit_error" } },
{ status: 429, headers: retry ? {} : { "x-should-retry": "false" } },
)
}
const trigger = JSON.stringify(bodies.at(-1)).includes("compaction_trigger")
if (state.overflow && (trigger || state.localFailure))
return Response.json(
{
error: {
message: "Your input exceeds the context window",
code: "context_length_exceeded",
type: "invalid_request_error",
},
},
{ status: 400 },
)
const checkpoint = {
type: "compaction",
id: `cmp_${state.calls}`,
encrypted_content: `encrypted_${state.calls}`,
}
if (new URL(request.url).pathname.endsWith("/compact"))
return Response.json({
id: "compact_endpoint",
object: "response.compaction",
output: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "endpoint retained" }] },
checkpoint,
],
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({
type: "response.completed",
response: {
id: `resp_${state.calls}`,
status: "completed",
output,
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) =>
Effect.sync(() => {
hanging.resolve(new Response("cancelled"))
void server.stop(true)
}),
)
const native = OpenAI.configure({ apiKey: "fixture", baseURL: server.url.toString() }).responses("gpt-5.4-mini")
const model = SessionRunnerModel.resolved(
endpoint
? LanguageModel.update(native, {
route: native.route.with({ compact: { endpoint: native.route.compact.endpoint } }),
})
: native,
{
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { mode: "provider" },
},
)
const sessionID = SessionSchema.ID.create()
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "native-compaction",
version: "test",
})
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die("Missing fixture session")
const instructions = Instructions.make({
key: Instructions.Key.make("test/native"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.succeed("Current instructions"),
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
yield* InstructionState.prepare(db, bus, instructions, sessionID)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-test-hook"] = event.kind
}),
)
yield* hooks.register("session", "http.request", (event) =>
Effect.sync(() => event.request.headers.set("x-http-hook", event.kind)),
)
const prompt = Effect.fnUntraced(function* (text: string, synthetic = false) {
const id = SessionMessage.ID.create()
yield* inbox.admit({
id,
sessionID,
item: { type: synthetic ? "synthetic" : "user", payload: { text }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
})
const load = Effect.gen(function* () {
const history = yield* SessionHistory.preview(
db,
sessionID,
instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session,
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("Compaction must never dispatch tools"),
},
}
})
const compact = Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: SessionMessage.ID.create(),
resolveContext: () => load,
prepare: requests.prepare,
})
})
const checkpoint = Effect.gen(function* () {
const messages = (yield* load).messages
const last = messages.findLast((message) => message.type === "compaction" && message.status === "completed")
if (last?.type !== "compaction" || last.status !== "completed" || !last.providerContext)
return yield* Effect.die("Missing native checkpoint")
expect(last.summary).toBe("")
expect(last.recent).toBe("")
return last.providerContext
})
return {
compact,
automatic: Effect.gen(function* () {
return yield* compaction.compact({ context: yield* load, prepare: requests.prepare })
}),
checkpoint,
prompt,
load,
requests,
bodies,
headers,
state,
blocked,
sessionID,
store,
hooks,
model,
}
})
it.live(
"manual trigger persists and continues, retains earlier users repeatedly, and preserves context on failure/cancellation",
() =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("First real user request")
yield* fixture.prompt("Synthetic context, not a user request", true)
expect(yield* fixture.compact).toEqual({ status: "completed" })
const first = yield* fixture.checkpoint
expect(SessionProviderContext.decode(first).map((message) => message.role)).toEqual(["user", "assistant"])
expect(JSON.stringify(first.messages)).not.toContain("Synthetic context")
expect(fixture.bodies[0]).toMatchObject({
input: expect.arrayContaining([{ type: "compaction_trigger" }]),
tools: [expect.objectContaining({ name: "read" })],
})
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
expect(fixture.headers[0]?.get("x-test-hook")).toBe("compaction")
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
yield* fixture.prompt("Second real user request")
const context = yield* fixture.load
const prepared = yield* fixture.requests.prepare({
kind: "primary",
scope: { session: context.session, model: context.model, agentID: context.agent.id, tools: context.tools },
transcript: SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }),
})
const client = yield* LLMClient.Service
yield* client.generate(prepared.request, prepared.options)
expect(JSON.stringify(fixture.bodies[1])).toContain("encrypted_1")
expect(JSON.stringify(fixture.bodies[1])).toContain("Current instructions")
expect(JSON.stringify(fixture.bodies[1])).toContain("Second real user request")
expect(yield* fixture.compact).toEqual({ status: "completed" })
const second = yield* fixture.checkpoint
expect(
SessionProviderContext.decode(second)
.filter((message) => message.role === "user")
.map((message) => message.content),
).toEqual([[Message.text("First real user request")], [Message.text("Second real user request")]])
expect(JSON.stringify(second.messages)).not.toContain("encrypted_1")
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 40, output: 8 } })
// Nothing new since the checkpoint is not compactable, exactly like a fresh local summary.
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "compaction.unavailable" } })
yield* fixture.prompt("Third real user request")
fixture.state.failure = true
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(4)
expect(yield* fixture.checkpoint).toEqual(second)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.compact.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.checkpoint).toEqual(second)
// A transient provider failure retries under the shared session policy and its plugin hook.
fixture.state.hang = false
const retries: PluginHooks.Domains["session"]["retry"][] = []
yield* fixture.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
retries.push(event)
event.decision = { retry: true, delay: 0 }
}),
)
fixture.state.flaky = true
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(7)
expect(retries).toMatchObject([
{
agent: "compaction",
attempt: 2,
error: { type: "provider.rate-limit" },
decision: { retry: true, delay: 0 },
},
])
expect(
SessionProviderContext.decode(yield* fixture.checkpoint).filter((message) => message.role === "user"),
).toHaveLength(3)
}),
15000,
)
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
Effect.gen(function* () {
const fixture = yield* setup(true)
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(yield* fixture.automatic).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")
expect(fixture.state.calls).toBe(2)
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
}),
)
it.live("only known automatic native overflow falls back locally and failed recovery retains the checkpoint", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original durable request")
expect(yield* fixture.compact).toEqual({ status: "completed" })
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(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)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.interrupted" },
})
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.hang = false
fixture.state.overflow = true
fixture.state.localFailure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed" })
expect(fixture.state.calls).toBe(5)
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" }),
)
}),
)
it.live("rejects request-hook route rewrites before provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original user")
yield* fixture.hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.baseURL = "https://another.example/v1"
}),
)
expect(yield* fixture.compact).toMatchObject({
status: "failed",
error: { type: "provider.unsupported-operation" },
})
expect(fixture.state.calls).toBe(0)
}),
)
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"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
const user = (text: string) =>
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text,
time: { created: DateTime.makeUnsafe(0) },
})
const newest = {
...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.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
).toHaveLength(1)
})
@@ -0,0 +1,303 @@
import { expect, test } from "bun:test"
import { CompactionPart, LanguageModel, Message, ToolCallPart } from "@opencode-ai/ai"
import { OpenAIResponses } from "@opencode-ai/ai/protocols"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionHistory } from "@opencode-ai/core/session/history"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Model } from "@opencode-ai/schema/model"
import { asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 128_000, output: 4096 },
},
)
const target = SessionProviderContext.provenance(model)
if (!target) throw new Error("Fixture must have a concrete endpoint")
const replacement = [
Message.user("retained request"),
Message.assistant(
CompactionPart.make({ provider: model.model.provider, encrypted: "opaque-checkpoint", id: "cp_1" }),
),
]
const providerContext = SessionProviderContext.encode(target, replacement)
const sessionID = SessionSchema.ID.make("ses_provider_context")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)
const setup = Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "provider-context",
version: "test",
})
const state = { value: "initial instructions" }
const instructions = Instructions.make({
key: Instructions.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => state.value),
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
const prepare = InstructionState.prepare(database.db, bus, instructions, sessionID)
const prompt = Effect.fnUntraced(function* (text: string) {
const id = SessionMessage.ID.create()
yield* inbox.admit({ id, sessionID, item: { type: "user", payload: { text }, delivery: "steer" } })
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
return id
})
const compact = (context?: SessionProviderContext.Info) =>
bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: context ? "" : "local summary",
recent: "",
providerContext: context,
})
const load = (boundary: SessionHistory.Boundary) =>
SessionHistory.entriesForRunner(database.db, sessionID, instructions, boundary)
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
})
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
const messages = [
...replacement,
Message.assistant(ToolCallPart.make({ id: "call_1", name: "read", input: { path: "file" } })),
Message.tool({ id: "call_1", name: "read", result: { text: "result" } }),
Message.user({ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) }),
]
const context = SessionProviderContext.encode(providerContext.provenance, messages)
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(SessionProviderContext.Info))(JSON.stringify(context))
const decoded = SessionProviderContext.decode(stored)
expect(decoded.slice(0, -1)).toEqual(messages.slice(0, -1))
expect(decoded.at(-1)?.content).toEqual([{ type: "media", mediaType: "image/png", data: "AQID" }])
const optionalMetadata = SessionProviderContext.encode(providerContext.provenance, [
Message.make({
role: "user",
content: [
{ type: "text", text: "attachment", metadata: { attachment: { name: undefined, source: { type: "inline" } } } },
],
providerMetadata: { openai: { itemId: undefined, type: "message", status: undefined, phase: undefined } },
}),
])
expect(SessionProviderContext.decode(optionalMetadata)[0]).toMatchObject({
providerMetadata: { openai: { type: "message" } },
content: [{ metadata: { attachment: { source: { type: "inline" } } } }],
})
expect(() =>
SessionProviderContext.decode({
...context,
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai" }] }],
}),
).toThrow()
})
test("compatibility uses the actual deployment and endpoint rather than a catalog alias or variant", () => {
expect(
SessionProviderContext.compatible(
providerContext.provenance,
SessionProviderContext.provenance({
...model,
ref: { ...model.ref, id: Model.ID.make("alias"), variant: Model.VariantID.make("high") },
}),
),
).toBe(true)
for (const changed of [
{ ...model, model: LanguageModel.update(model.model, { id: "other-deployment" }) },
{
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { baseURL: "https://another.example/v1?api-key=secret" } }),
}),
},
{ ...model, model: LanguageModel.update(model.model, { route: model.model.route.with({ id: "other-route" }) }) },
])
expect(
SessionProviderContext.compatible(providerContext.provenance, SessionProviderContext.provenance(changed)),
).toBe(false)
const privateEndpoint = SessionProviderContext.provenance({
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { baseURL: "https://user:secret@example.com/v1?api-key=secret" } }),
}),
})
expect(JSON.stringify(privateEndpoint)).not.toContain("secret")
expect(
SessionProviderContext.provenance({
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { path: () => "/dynamic" } }),
}),
}),
).toBeUndefined()
expect(SessionProviderContext.compatible(providerContext.provenance, undefined)).toBe(false)
})
it.effect(
"advances the native instruction epoch and omits superseded chronological updates after durable replay and provider switches",
() =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("original request")
s.state.value = "changed instructions"
yield* s.prepare
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
const completed = yield* s.compact(providerContext)
s.state.value = "newest instructions"
yield* s.prepare
yield* s.prompt("continue")
const verify = Effect.gen(function* () {
expect(
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toMatchObject({
epoch_start: completed.durable.seq,
initial_values: { "test/context": Instructions.hash("changed instructions") },
current_values: { "test/context": Instructions.hash("newest instructions") },
})
const native = yield* s.load(target)
expect(native.initial).toBe("changed instructions")
expect(
toLLMMessages(
native.entries.map((entry) => entry.message),
model.ref,
),
).toEqual([
...replacement,
Message.system("newest instructions"),
expect.objectContaining({ role: "user", content: [Message.text("continue")] }),
])
for (const incompatible of [
"local" as const,
{ ...providerContext.provenance, modelID: "other" },
{ ...providerContext.provenance, provider: "other" },
]) {
const expanded = yield* s.load(incompatible)
expect(expanded.initial).toBe("changed instructions")
expect(
toLLMMessages(
expanded.entries.map((entry) => entry.message),
model.ref,
).map((message) => message.content),
).toEqual([
[Message.text("original request")],
[Message.text("newest instructions")],
[Message.text("continue")],
])
}
const preview = yield* SessionHistory.preview(s.db, sessionID, s.instructions, target)
expect(preview.initial).toBe("changed instructions")
expect(preview.messages).toEqual(native.entries.map((entry) => entry.message))
const store = yield* SessionStore.Service
expect((yield* store.messages({ sessionID })).map((message) => message.type)).toEqual([
"user",
"system",
"compaction",
"system",
"user",
])
})
yield* verify
const recorded = yield* s.db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
expect(recorded.filter((event) => event.data.providerContext !== undefined)).toHaveLength(1)
yield* s.bus.remove(sessionID)
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
for (const event of recorded)
yield* s.bus.replay({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
})
yield* verify
}),
)
it.effect("falls back to an earlier compatible native or local checkpoint", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("before local")
s.state.value = "local baseline"
yield* s.prepare
yield* s.compact()
yield* s.prompt("after local")
yield* s.compact(providerContext)
yield* s.prompt("after native")
s.state.value = "new native baseline"
yield* s.prepare
yield* s.compact({ ...providerContext, provenance: { ...providerContext.provenance, modelID: "other" } })
s.state.value = "post-epoch update"
yield* s.prepare
const native = yield* s.load(target)
expect(native.initial).toBe("new native baseline")
expect(native.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "system"])
expect(native.entries[0]?.message).toMatchObject({ providerContext })
expect(
toLLMMessages(
native.entries.map((entry) => entry.message),
model.ref,
).filter((message) => message.role === "system"),
).toEqual([Message.system("post-epoch update")])
const local = yield* s.load("local")
expect(local.initial).toBe("new native baseline")
expect(local.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "user", "system"])
expect(local.entries[0]?.message).toMatchObject({ summary: "local summary" })
}),
)
it.effect("rejects malformed persisted native windows instead of silently dropping them", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.compact({ ...providerContext, messages: [{ role: "invalid", content: [] }] })
expect(yield* SessionHistory.load(s.db, sessionID, target).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageDecodeError",
})
const store = yield* SessionStore.Service
expect(yield* store.context(sessionID).pipe(Effect.flip)).toMatchObject({ _tag: "Session.MessageDecodeError" })
}),
)
+174 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import {
AIError,
CompactionPart,
CompactionCheckpointResponse,
HttpContext,
LLMEvent,
LLMRequest,
@@ -40,6 +42,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -192,7 +195,7 @@ test("does not apply an ineligible tier without base pricing", () => {
).toBe(Money.USD.zero)
})
const makeRunnerState = () => {
const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"]) => {
let toolBarrier: ToolBarrier | undefined
const releaseTools = (barrier: ToolBarrier) =>
Effect.sync(() => {
@@ -200,6 +203,7 @@ const makeRunnerState = () => {
}).pipe(Effect.andThen(Deferred.succeed(barrier.release, undefined)), Effect.asVoid)
return {
currentModel: model,
compaction,
modelResolveHook: Effect.void,
systemBaseline: "Initial context",
systemRemoved: false,
@@ -319,6 +323,7 @@ const layer = Layer.unwrap(
cost: [],
limit: modelLimits.get(String(selected.id)) ?? defaultModelLimit,
variant: session.model?.variant,
compaction: state.compaction,
})
}),
),
@@ -1435,6 +1440,92 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.inbox).toEqual([])
})
scenario(
"restores installed native context with auto disabled and preserves it across fork and revert",
function* (s) {
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => editor.configure({ auto: false }))
yield* s.runPrompt("Original request")
s.systemBaseline = "Checkpoint instructions"
yield* s.runPrompt("Before checkpoint")
const target = SessionProviderContext.provenance({
model: s.currentModel,
ref: Model.Ref.make({
id: Model.ID.make(s.currentModel.id),
providerID: Provider.ID.make(s.currentModel.provider),
}),
})
if (!target) throw new Error("Expected concrete fixture endpoint")
const replacement = [
Message.assistant(CompactionPart.make({ provider: s.currentModel.provider, encrypted: "checkpoint" })),
]
const providerContext = SessionProviderContext.encode(target, replacement)
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: "",
recent: "",
providerContext,
})
const checkpoint = (yield* s.messages).find((message) => message.type === "compaction")
if (!checkpoint) throw new Error("Expected checkpoint")
s.systemBaseline = "Newest instructions"
const after = yield* s.runPrompt("After checkpoint")
const continued = s.requests.at(-1)
if (!continued) throw new Error("Expected continuation request")
expect(continued.messages[0]).toEqual(replacement[0])
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
expect(systemTexts(continued)).toEqual(["Newest instructions"])
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[0])
expect(s.requests.at(-1)?.system.map((part) => part.text)).toContain("Newest instructions")
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
Message.system("Newest instructions"),
])
expect(
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
).toMatchObject({ providerContext })
const original = s.currentModel
s.currentModel = LanguageModel.update(original, { id: "different-deployment" })
yield* s.session.prompt({ sessionID: forked.id, text: "Switched fork", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
Message.system("Newest instructions"),
])
s.currentModel = original
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: checkpoint.id })
yield* s.runPrompt("After revert")
expect(
s.requests
.at(-1)
?.messages.flatMap((message) => message.content)
.some((part) => part.type === "compaction"),
).toBe(false)
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
expect(
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
).toMatchObject({ providerContext })
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.baseURL = "https://another-deployment.example/v1"
}),
)
const before = s.requests.length
yield* s.session.prompt({ sessionID: forked.id, text: "Changed route", resume: false })
expect(yield* s.session.resume(forked.id).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
expect(s.requests).toHaveLength(before)
},
)
scenario("seeds a fork with the parent's newest instruction values", function* (s) {
yield* s.runPrompt("First")
s.systemBaseline = "Changed context"
@@ -2678,6 +2769,88 @@ 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 })
s.compaction = { mode: "provider", threshold: 10_000 }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
agent.steps = 2
}),
)
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("First real request")
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
responseID: `resp_${encrypted}`,
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted },
})
yield* s.llm.push(
checkpoint("first"),
TestLLM.tool("echo-native", "echo", { text: "continue" }),
TestLLM.text("No usage yet", "no-usage"),
)
yield* s.runPrompt("Second real request")
expect(s.requests).toHaveLength(4)
expect(s.requests[2].toolChoice).not.toEqual({ type: "none" })
expect(s.executions).toEqual(["continue"])
expect(JSON.stringify(s.requests[2].messages)).toContain("first")
const installed = (yield* s.messages).filter((message) => message.type === "compaction")
expect(installed).toMatchObject([{ status: "completed", reason: "auto", providerContext: { version: 1 } }])
// New input without a post-checkpoint usage anchor must not retrigger compaction.
yield* s.llm.push(TestLLM.textWithUsage("Measured", "measured", 10_000))
yield* s.runPrompt("Third real request")
expect(s.requests).toHaveLength(5)
yield* s.llm.push(checkpoint("second"), TestLLM.textWithUsage("Continued", "continued", 10_000))
yield* s.runPrompt("Fourth real request")
expect(s.requests).toHaveLength(7)
expect(userTexts(s.requests[6])).toEqual([
"First real request",
"Second real request",
"Third real request",
"Fourth real request",
])
expect(JSON.stringify(s.requests[6].messages)).not.toContain('"encrypted":"first"')
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => editor.configure({ auto: false }))
yield* replaySessionProjection(sessionID)
yield* s.llm.push(TestLLM.text("Disabled auto still replays", "disabled"))
yield* s.runPrompt("Fifth real request")
expect(s.requests).toHaveLength(8)
expect(JSON.stringify(s.requests[7].messages)).toContain('"encrypted":"second"')
})
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
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.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"),
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(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recovered original history" },
{ type: "assistant" },
])
})
scenario("does not compact immediately when the advertised output limit fills the context", function* (s) {
s.currentModel = fullOutputModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500))
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileAccess.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -91,7 +91,7 @@ const withTool = <A, E, R>(
return yield* body(registry)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, editToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
read: (target, range) =>
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileAccess.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, patchToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
read: (target, range) =>
+73 -66
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer } from "effect"
import { Effect, Exit, Layer, Result } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -12,7 +12,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { location } from "./fixture/location"
import { Tool } from "@opencode-ai/core/tool"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
@@ -30,7 +30,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
LocationMutation.node,
FileAccess.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -47,7 +47,7 @@ const readCalls: {
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: AbsolutePath[] = []
let resolveFailure: unknown
let readDefect: unknown
let directoryEntries: string[] = []
let directoryEntryDetails: Environment.DirEntry[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
@@ -69,7 +69,7 @@ const reader = Layer.succeed(
},
read: (input, resource, page = {}) => {
readCalls.push({ input, page })
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readDefect !== undefined) return Effect.die(readDefect)
if (readOverride) return readOverride(input, resource, page)
if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult)
@@ -77,13 +77,14 @@ const reader = Layer.succeed(
}),
)
let allow = true
let deniedResource: string | undefined
const permission = permissionLayer({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(
Effect.andThen(
allow
allow && !input.resources.some((resource) => resource === deniedResource)
? Effect.void
: Effect.fail(
new Permission.BlockedError({
@@ -112,30 +113,6 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
)
const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
const directory = path.dirname(absolute)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
absolute,
resource,
externalDirectory: external
? {
action: "external_directory" as const,
directory,
resource: externalResource,
save: externalResource,
}
: undefined,
})
},
}),
)
const unavailableImage = Layer.mock(Image.Service, {
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
})
@@ -146,7 +123,6 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
Permission.node.replace(permission),
Config.node.replace(config),
Image.node.replace(imageLayer),
LocationMutation.node.replace(mutation),
FSUtil.node.replace(testFileSystem),
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
@@ -165,7 +141,8 @@ describe("ReadTool", () => {
readCalls.length = 0
listCalls.length = 0
allow = true
resolveFailure = undefined
deniedResource = undefined
readDefect = undefined
directoryEntries = []
directoryEntryDetails = []
readResult = {
@@ -620,18 +597,21 @@ describe("ReadTool", () => {
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
resolveFailure = new Error("unexpected")
readDefect = new Error("unexpected")
const registry = yield* Tool.Service
expect(
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
}).pipe(Effect.exit),
),
).toBe(true)
const exit = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
}).pipe(Effect.exit)
expect(Result.getOrThrow(Exit.findDefect(exit))).toBe(readDefect)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
page: { offset: undefined, limit: undefined },
},
])
}),
)
@@ -721,6 +701,57 @@ describe("ReadTool", () => {
}),
)
it.effect("recovers an external filename without repeating directory approval", () =>
Effect.gen(function* () {
const directory = path.join(path.parse(process.cwd()).root, "external-read")
const requested = path.join(directory, "report final.txt")
const recovered = path.join(directory, "report\u202ffinal.txt")
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
readOverride = (input) =>
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-external-recovery", name: "read", input: { path: requested } },
}),
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{ action: "external_directory", resources: [path.join(directory, "*").replaceAll("\\", "/")] },
{ action: "read", resources: [requested.replaceAll("\\", "/")] },
{ action: "read", resources: [recovered.replaceAll("\\", "/")] },
])
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested), AbsolutePath.make(recovered)])
}),
)
it.effect("does not read a recovered filename denied by its own read rules", () =>
Effect.gen(function* () {
const requested = path.join(process.cwd(), "report final.txt")
const recovered = path.join(process.cwd(), "report\u202ffinal.txt")
deniedResource = path.basename(recovered)
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
readOverride = (input) =>
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-recovery", name: "read", input: { path: requested } },
}),
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
expect(assertions).toMatchObject([
{ action: "read", resources: [path.basename(requested)] },
{ action: "read", resources: [path.basename(recovered)] },
])
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested)])
}),
)
it.effect("does not recover ambiguous files", () =>
Effect.gen(function* () {
const requested = "report final.txt"
@@ -860,30 +891,6 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
resolveFailure = new Error("missing")
expect(
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
}),
)
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.TextPage({
+3 -3
View File
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
+2 -2
View File
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -131,7 +131,7 @@ const shellPluginSupervisor = makeLocationNode({
deps: [
Config.node,
Environment.node,
LocationMutation.node,
FileAccess.node,
Permission.node,
Session.node,
Job.node,
+3 -3
View File
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -79,7 +79,7 @@ const withTool = <A, E, R>(
return yield* body(registry)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
write: (target, content) =>
+267 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -14384,6 +14523,9 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14489,6 +14631,9 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16432,6 +16577,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17365,6 +17513,36 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17387,6 +17565,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18355,6 +18536,9 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18441,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18472,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -18903,6 +19122,46 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
+26
View File
@@ -30,6 +30,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Location } from "@opencode-ai/schema/location"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { EventLog } from "@opencode-ai/schema/event-log"
import { FileDiff } from "@opencode-ai/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -521,6 +522,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
messageID: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+2
View File
@@ -41,6 +41,7 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -58,6 +59,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
+1
View File
@@ -106,6 +106,7 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+8 -1
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, statics } from "./schema.js"
import { optional, PositiveInt, statics } from "./schema.js"
export const ID = Schema.String.pipe(
Schema.brand("Provider.ID"),
@@ -28,6 +28,12 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
@@ -52,6 +58,7 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
+1
View File
@@ -587,6 +587,7 @@ export namespace Compaction {
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
text: Schema.String,
recent: Schema.String,
},
+16
View File
@@ -1,6 +1,7 @@
export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
import { Location } from "./location.js"
@@ -254,6 +255,7 @@ export const CompactionCompleted = Schema.Struct({
providerState: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
providerContext: SessionProviderContext.Info.pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
@@ -270,6 +272,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -281,6 +295,7 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -293,4 +308,5 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
@@ -0,0 +1,24 @@
export * as SessionProviderContext from "./session-provider-context.js"
import { Schema } from "effect"
import { Provider } from "./provider.js"
/** Exact producing model/deployment and route identity, never credentials or a connection ID. */
export interface Provenance extends Schema.Schema.Type<typeof Provenance> {}
export const Provenance = Schema.Struct({
providerID: Provider.ID,
provider: Schema.String,
modelID: Schema.String,
route: Schema.String,
protocol: Schema.String,
/** Digest of the configured endpoint; raw URLs and query values are not persisted. */
endpoint: Schema.String,
}).annotate({ identifier: "Session.ProviderContext.Provenance" })
/** Core validates the versioned canonical AI Message[] payload on installation and replay. */
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
version: Schema.Literal(1),
provenance: Provenance,
messages: Schema.Json,
}).annotate({ identifier: "Session.ProviderContext" })
+19
View File
@@ -56,6 +56,25 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
const model = Model.Info.default(Provider.ID.make("custom"), Model.ID.make("gpt-5.6"))
@@ -48,3 +48,25 @@ test("failed steps only override the assistant finish for content filters", () =
})
expect(() => decode({ ...input, finish: "stop" })).toThrow()
})
test("provider compaction context is optional, versioned and JSON-only", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
const encode = Schema.encodeSync(SessionEvent.Compaction.Ended.data)
const local = { sessionID: "ses_context", reason: "manual" as const, text: "summary", recent: "" }
expect(encode({ ...decode(local), providerContext: undefined })).toEqual(local)
const providerContext = {
version: 1 as const,
provenance: {
providerID: "openai",
provider: "openai",
modelID: "deployment",
route: "responses",
protocol: "responses",
endpoint: "digest",
},
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai", encrypted: "opaque" }] }],
}
expect(encode(decode({ ...local, providerContext }))).toEqual({ ...local, providerContext })
expect(() => decode({ ...local, providerContext: { ...providerContext, version: 2 } })).toThrow()
expect(() => decode({ ...local, providerContext: { ...providerContext, messages: [() => "invalid"] } })).toThrow()
})
+23 -1
View File
@@ -1,5 +1,6 @@
import { Session } from "@opencode-ai/core/session"
import { SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
import type { Snapshot } from "@opencode-ai/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+33 -62
View File
@@ -17,10 +17,9 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode-ai/protocol/errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { failedMessageDecode, missingSession } from "./session-error"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -212,15 +211,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -448,32 +439,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -481,23 +454,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -527,6 +490,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
@@ -642,15 +621,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
const message = yield* session.updateMessage({ ...ctx.params, content: ctx.payload.content }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag(
"Session.MessageNotAssistantError",
+98
View File
@@ -0,0 +1,98 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "messageID" },
})
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
@@ -388,7 +388,13 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
return (
<div data-component="session-compaction-message">
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.messagePart.compaction")} />
<TimelineSeparator
label={i18n.t(
props.message.status === "completed" && props.message.providerContext
? "ui.messagePart.providerCompaction"
: "ui.messagePart.compaction",
)}
/>
</div>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -765,7 +765,8 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
+5 -1
View File
@@ -2113,7 +2113,11 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>Compaction</text>
<text fg={color()}>
{props.message.status === "completed" && props.message.providerContext
? "Provider compaction"
: "Compaction"}
</text>
<Show when={cancelled()}>
<text fg={color()}>· cancelled</text>
</Show>
+25 -5
View File
@@ -171,8 +171,18 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const appendPart = (ref: PartRef, part: AppendPart) =>
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
append(draft, ref, part, queuedStart(draft))
if (!hasPart(draft, ref)) {
append(draft, ref, part, queuedStart(draft))
return
}
if (part.type !== "reasoning" || part.time?.completed === undefined) return
const row = draft.find(
(row) =>
row.type === "group" &&
row.kind === "reasoning" &&
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
)
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
}),
)
@@ -250,7 +260,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ type: "reasoning" },
{ type: "reasoning", time: { completed: event.created } },
)
}),
data.on("session.tool.input.started", (event) => {
@@ -295,6 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "idle") return rows
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
@@ -437,17 +448,26 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
return message.content.filter((part) => part.type === match[1])[ordinal]
}
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
if (part.type === "reasoning") {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
rows.splice(index, 0, {
type: "group",
kind: "reasoning",
refs: [ref],
completed: part.time?.completed !== undefined,
})
return
}
if (part.type === "tool" && exploration(part.name)) {
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { expect, setSystemTime, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider } from "../../src/context/client"
@@ -182,16 +182,21 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
// Keep click timing independent of renderer delays on busy CI runners.
setSystemTime(new Date(1_000))
await app.mockMouse.doubleClick(5, 0)
expect(promoted).toEqual([])
setSystemTime(new Date(2_000))
await app.mockMouse.click(40, 0)
expect(active()).toBe("second")
expect(promoted).toEqual([])
setSystemTime(new Date(2_100))
await app.mockMouse.click(40, 0)
expect(promoted).toEqual(["second"])
} finally {
setSystemTime()
app.renderer.destroy()
}
})
+1
View File
@@ -104,6 +104,7 @@ const source = {
"ui.messagePart.review.title": "Review your answers",
"ui.messagePart.questions.dismissed": "Questions dismissed",
"ui.messagePart.compaction": "Session compacted",
"ui.messagePart.providerCompaction": "Session compacted by provider",
"ui.messagePart.context.details": "Details",
"ui.messagePart.context.read.one": "{{count}} read",
"ui.messagePart.context.read.other": "{{count}} reads",
@@ -83,6 +83,10 @@ For most permissions, you can use an object to apply different actions based on
"edit": {
"*": "deny",
"packages/web/src/content/docs/*.mdx": "allow"
},
"webfetch": {
"*": "ask",
"https://en.wikipedia.org/*": "allow"
}
}
}
@@ -159,7 +163,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards:
- `lsp` — running LSP queries (currently non-granular)
- `question` — asking the user questions during execution
- `webfetch` — fetching a URL (matches the URL)
- `websearch` — web search (matches the query)
- `websearch` — web search
- `external_directory` — triggered when a tool touches paths outside the project working directory
- `doom_loop` — triggered when the same tool call repeats 3 times with identical input
+267 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -14384,6 +14523,9 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14489,6 +14631,9 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16432,6 +16577,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17365,6 +17513,36 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17387,6 +17565,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18355,6 +18536,9 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18441,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18472,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -18903,6 +19122,46 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
+267 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -14384,6 +14523,9 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14489,6 +14631,9 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16432,6 +16577,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17365,6 +17513,36 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17387,6 +17565,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18355,6 +18536,9 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18441,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18472,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -18903,6 +19122,46 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
+63 -8
View File
@@ -18,18 +18,19 @@ the size of the final system prompt, messages, and advertised tools. It starts
compaction when:
```text
estimated tokens > context limit - max(requested output tokens, buffer)
estimated tokens >= min(input limit - buffer, context limit - max(output reserve, buffer))
```
The estimate is approximate: V2 JSON-serializes the request and assumes four
characters per token. When compaction succeeds, V2 rebuilds the request from
the new checkpoint and retries the step without promoting the input again.
The estimate uses the latest model response's input usage plus output and newer
content. Without usage, it estimates text, media, instructions, and tools locally.
The output reserve is capped at 32,000 tokens; an absent input limit does not
constrain the ceiling. Successful compaction rebuilds the request without promoting
input again or spending another agent step.
V2 also recognizes provider errors classified as context overflow. If an
overflow occurs before the provider produces assistant output or other retry
evidence, V2 can compact and retry that step once. This recovery is attempted
even when `auto` is `false`; `auto` controls only the preflight size check. A
second overflow after recovery is returned as an error.
only when `auto` is enabled. A second overflow after recovery is returned as an error.
## Manual compaction
@@ -75,7 +76,60 @@ Add `compaction` to any [OpenCode configuration file](/config):
preserves more recent detail but leaves less room for future work. Larger
`buffer` triggers preflight compaction earlier.
## Checkpoint contents
## Provider compaction
By default, compaction generates a local text summary. To use the selected
provider's native compaction operation for automatic and manual requests, set a
provider policy. An individual model's policy replaces the entire provider policy:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"compaction": { "mode": "provider", "threshold": 120000 },
"models": {
"gpt-5.4-mini": { "compaction": { "mode": "provider" } },
"gpt-4.1": { "compaction": { "mode": "local" } },
},
},
},
}
```
- `threshold` is an optional positive integer in provider mode. Omit it to use the
selected model's usable input ceiling above. A configured threshold is clamped
to that ceiling. In this example, `gpt-5.4-mini` uses its own ceiling, not 120,000.
- Scheduling uses the normal safe session step boundaries, not in-band provider
context management. `compaction.auto: false` disables all new automatic work.
- After installing a native checkpoint, automatic checks wait for a fresh model
usage anchor. Encrypted checkpoint bytes are not a meaningful token count.
- OpenAI Responses uses a streamed compaction trigger when the route supports it.
Endpoint-only routes use their standalone compaction endpoint. Deployment/model
support can vary. Transient provider failures retry under the same session retry
policy and plugin hook as other requests; nothing is installed until a checkpoint
is returned.
- A known automatic context overflow uses local recovery over the durable original
history, re-expanding native checkpoints. This applies both to ordinary model
calls and native compaction rejection. If local recovery fails, the prior
checkpoint remains intact and the error surfaces. Authentication, rate limits,
cancellation, and other failures do not trigger local fallback. Manual native
compaction also surfaces errors without fallback.
- Unsupported routes are rejected during model resolution. Configure custom
endpoints through provider/model `settings.baseURL`, not a `model.request` hook;
native compaction rejects endpoint rewrites by that hook.
- Trigger checkpoints retain whole, real user messages and attachments up to the
`compaction.tokens` budget, including users retained across earlier native
compactions. Synthetic guidance is not retained as user input. Endpoint results
are stored as the provider returned them. Neither path fabricates a text summary.
Keep `threshold` comfortably above `tokens` plus the system prompt and tools, or
every step will compact again as soon as the next response reports usage.
- Successful native checkpoints advance the instruction epoch and are replayed
only with a matching provider, model, protocol, and endpoint. Switching to an
incompatible route reuses an earlier compatible checkpoint or retained transcript.
Disabling automatic compaction does not remove an installed checkpoint.
## Local checkpoint contents
V2 uses the session's selected agent, model, and variant to generate the summary.
The request reuses the normal instructions, tool definitions, and structured
@@ -119,7 +173,8 @@ read. See [Instructions](/instructions) for source ordering and update behavior.
## Current limitations
- Compaction requires a resolvable model with a positive catalog context limit.
- Compaction requires a resolvable model. Automatic scheduling needs a positive
catalog context limit; manual and overflow recovery do not.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
its output allowance, the model returns no summary, or the provider fails.
+23 -1
View File
@@ -354,7 +354,29 @@ Control automatic context compaction and how much recent context it preserves.
}
```
See the [compaction guide](/compaction) for automatic context management.
Local summaries remain the default. Opt into native provider compaction for both
automatic and manual requests with a provider or model policy:
```jsonc
{
"providers": {
"openai": {
"compaction": { "mode": "provider", "threshold": 120000 },
"models": {
"gpt-4.1": { "compaction": { "mode": "local" } },
},
},
},
}
```
A model policy replaces the whole provider policy. The optional positive integer
`threshold` defaults to the selected model's usable input budget and cannot exceed
its safe ceiling. Provider checkpoints keep recent user messages within the same
`compaction.tokens` budget that local summaries use for their retained tail.
Top-level `compaction.auto: false` disables new automatic compaction without
discarding installed checkpoints. See the [compaction guide](/compaction) for
budgeting and overflow recovery.
### Session warming