mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5086d127 | ||
|
|
b20482461c | ||
|
|
5b5368fe98 | ||
|
|
582a2108ce | ||
|
|
9c65a69937 | ||
|
|
1d391908f4 | ||
|
|
596dca4dee | ||
|
|
1827832775 | ||
|
|
898692af26 |
@@ -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)
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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> = (
|
||||
@@ -1133,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 }>(
|
||||
|
||||
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
|
||||
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"
|
||||
@@ -192,7 +200,7 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
|
||||
|
||||
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
|
||||
|
||||
export type ProviderCompaction = { mode: "local" | "provider" }
|
||||
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
|
||||
|
||||
export type ModelCapabilities = {
|
||||
tools: boolean
|
||||
@@ -2177,6 +2185,7 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3122,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"]
|
||||
@@ -3413,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"]
|
||||
@@ -3704,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"]
|
||||
@@ -4193,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"]
|
||||
|
||||
+75
-64
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -96,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 = {
|
||||
@@ -127,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> {
|
||||
@@ -139,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(
|
||||
@@ -442,6 +447,10 @@ export const layer = Layer.effect(
|
||||
}
|
||||
/** 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) =>
|
||||
@@ -466,19 +475,29 @@ export const layer = Layer.effect(
|
||||
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* () {
|
||||
// One physical attempt, with no local-summary fallback or provider-error retry.
|
||||
// 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" })
|
||||
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 })
|
||||
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`),
|
||||
@@ -503,13 +522,21 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
).pipe(
|
||||
Effect.onInterrupt(() => interrupted(input)),
|
||||
Effect.catchTag("AI.Error", (cause) =>
|
||||
failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
inputID: input.inputID,
|
||||
error: toSessionError(cause),
|
||||
}),
|
||||
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),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -545,8 +572,12 @@ export const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
])
|
||||
const retry = yield* SessionRunnerRetry.policy(context.session.id)
|
||||
// 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, {
|
||||
@@ -607,23 +638,7 @@ 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)
|
||||
@@ -659,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
|
||||
@@ -674,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)
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -212,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
|
||||
}
|
||||
@@ -256,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),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -226,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 [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -42,9 +42,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode-ai/ai/providers/openai/responses",
|
||||
compaction: { mode: "provider" },
|
||||
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" },
|
||||
},
|
||||
@@ -58,7 +60,14 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
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" })
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const blocked = Deferred.makeUnsafe<void>()
|
||||
const hanging = Promise.withResolvers<Response>()
|
||||
const state = { failure: false, hang: false, calls: 0 }
|
||||
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(
|
||||
@@ -74,10 +74,26 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
Deferred.doneUnsafe(blocked, Effect.void)
|
||||
return hanging.promise
|
||||
}
|
||||
if (state.failure)
|
||||
// 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 },
|
||||
{ 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",
|
||||
@@ -94,9 +110,27 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
],
|
||||
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
|
||||
})
|
||||
const output = JSON.stringify(bodies.at(-1)).includes("compaction_trigger") ? [checkpoint] : []
|
||||
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(
|
||||
`data: ${JSON.stringify({
|
||||
`${summary}data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp_${state.calls}`,
|
||||
@@ -210,6 +244,9 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
})
|
||||
return {
|
||||
compact,
|
||||
automatic: Effect.gen(function* () {
|
||||
return yield* compaction.compact({ context: yield* load, prepare: requests.prepare })
|
||||
}),
|
||||
checkpoint,
|
||||
prompt,
|
||||
load,
|
||||
@@ -271,30 +308,94 @@ it.live(
|
||||
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("endpoint-only compaction keeps the provider replacement unchanged", () =>
|
||||
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(1)
|
||||
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()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionCheckpointResponse,
|
||||
HttpContext,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -194,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(() => {
|
||||
@@ -202,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,
|
||||
@@ -321,6 +323,7 @@ const layer = Layer.unwrap(
|
||||
cost: [],
|
||||
limit: modelLimits.get(String(selected.id)) ?? defaultModelLimit,
|
||||
variant: session.model?.variant,
|
||||
compaction: state.compaction,
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -2766,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))
|
||||
|
||||
+209
-16
@@ -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"],
|
||||
@@ -17375,15 +17514,34 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.Compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["local", "provider"]
|
||||
"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
|
||||
}
|
||||
},
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false
|
||||
]
|
||||
},
|
||||
"Provider.Info": {
|
||||
"type": "object",
|
||||
@@ -18467,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": [
|
||||
{
|
||||
@@ -18498,6 +18688,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,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,10 +28,11 @@ export type Package = typeof Package.Type
|
||||
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
|
||||
export type Activation = typeof Activation.Type
|
||||
|
||||
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
|
||||
export const Compaction = Schema.Struct({
|
||||
mode: Schema.Literals(["local", "provider"]),
|
||||
}).annotate({ identifier: "Provider.Compaction" })
|
||||
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),
|
||||
|
||||
@@ -272,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,
|
||||
@@ -283,6 +295,7 @@ export const Info = Schema.Union([
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
Idle,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
@@ -295,4 +308,5 @@ export type Info =
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -63,6 +63,15 @@ describe("Model.Info", () => {
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+209
-16
@@ -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"],
|
||||
@@ -17375,15 +17514,34 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.Compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["local", "provider"]
|
||||
"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
|
||||
}
|
||||
},
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false
|
||||
]
|
||||
},
|
||||
"Provider.Info": {
|
||||
"type": "object",
|
||||
@@ -18467,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": [
|
||||
{
|
||||
@@ -18498,6 +18688,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
@@ -17375,15 +17514,34 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.Compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["local", "provider"]
|
||||
"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
|
||||
}
|
||||
},
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false
|
||||
]
|
||||
},
|
||||
"Provider.Info": {
|
||||
"type": "object",
|
||||
@@ -18467,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": [
|
||||
{
|
||||
@@ -18498,6 +18688,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,19 +76,20 @@ 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.
|
||||
|
||||
## Provider compaction (manual)
|
||||
## Provider compaction
|
||||
|
||||
By default, compaction generates a local text summary. To use the selected
|
||||
provider's native compaction operation for manual requests, set a provider policy.
|
||||
An individual model's policy overrides it:
|
||||
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" },
|
||||
"compaction": { "mode": "provider", "threshold": 120000 },
|
||||
"models": {
|
||||
"gpt-5.4-mini": { "compaction": { "mode": "provider" } },
|
||||
"gpt-4.1": { "compaction": { "mode": "local" } },
|
||||
},
|
||||
},
|
||||
@@ -95,11 +97,24 @@ An individual model's policy overrides it:
|
||||
}
|
||||
```
|
||||
|
||||
- Automatic and overflow compaction still use local summaries. This policy only
|
||||
changes manual compaction; it does not enable in-band provider context management.
|
||||
- `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; provider errors are returned without a retry or local fallback.
|
||||
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.
|
||||
@@ -107,6 +122,8 @@ An individual model's policy overrides it:
|
||||
`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.
|
||||
@@ -156,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user