Compare commits

..
64 changed files with 3978 additions and 566 deletions

No files matched your search

+1
View File
@@ -133,6 +133,7 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
}
}
@@ -8,6 +8,29 @@ import { expectSessionTitle } from "../utils/waits"
test.use({ viewport: { width: 1440, height: 900 } })
for (const view of ["desktop", "mobile"] as const) {
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
const requests: { directory: string; provider?: string }[] = []
const workspace = await openSession(page, {
name: "ReviewWithoutGit",
project: { vcs: undefined },
onVcsInit: (input) => requests.push(input),
})
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]')
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
const init = panel.getByRole("button", { name: "Create Git repository" })
await init.click()
await expect.poll(() => requests).toEqual([{ directory: workspace.directory, provider: "git" }])
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
await expect(init).toHaveCount(0)
})
}
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
const directory = "C:/OpenCode/ReviewOpenFile"
@@ -200,9 +200,11 @@ test("terminal stacks under review by default and spans the bottom when configur
await expect
.poll(() =>
page.evaluate(() => {
const review = document.querySelector("#review-panel")!.getBoundingClientRect()
const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect()
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')!
const review = document.querySelector("#review-panel")?.getBoundingClientRect()
const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect()
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')
// The side region appears before its lazy review content after reload.
if (!review || !terminal || !sidebar) return false
const gap = terminal.top - review.bottom
return (
terminal.left <= 9 &&
+1
View File
@@ -169,6 +169,7 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.post("vcsInit", "/api/vcs/init", { success: NoContent, error: Unsupported }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { query: Query, success: Json }))
+20
View File
@@ -43,6 +43,8 @@ export interface MockServerConfig {
cursor?: string
}
vcs?: { current: string; default: string }
// Initializes the mock project's VCS; without a handler this mutation answers 501.
onVcsInit?: (input: { directory: string; provider?: string }) => void
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
// Benchmark latency only. Tests hold message pages with `beforeMessagesResponse`.
messageDelay?: number
@@ -817,6 +819,24 @@ function mockHandlers(
location: location(config),
data: { branch: config.vcs ?? { current: "main", default: "main" } },
}),
vcsInit: (ctx) => {
if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit")
return Effect.sync(() => {
const url = new URL(ctx.request.url, "http://localhost")
const provider = url.searchParams.get("provider") ?? undefined
config.onVcsInit?.({ directory: requestDirectory(config, ctx.request), provider })
const project = config.project as { id: string; vcs?: string }
project.vcs = provider ?? "git"
state.emit([
{
id: "evt_vcs_initialized",
type: "worktree.updated",
created: Date.now(),
data: { projectID: project.id },
},
])
}).pipe(Effect.andThen(noContent))
},
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }),
vcsDiff: (ctx) =>
+1
View File
@@ -39,6 +39,7 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
+1 -1
View File
@@ -115,7 +115,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
const acp = ACPConnection.make(app, stream)
const connection = acp.connection
const sessions = yield* ACPSessions.make({ client, connection, catalog })
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false })
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false, compaction: false })
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
return acp.agent
+148
View File
@@ -0,0 +1,148 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, SessionMessageInfo, SessionStructuredError } from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
const MarkerMeta = "opencode/compaction"
/** Message IDs follow core's compaction message projection, so live compactions match replayed ones. */
export type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
type Compaction =
| Started
| {
readonly status: "completed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly summary: string
}
| {
readonly status: "failed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly error: SessionStructuredError
}
/** Each session's compaction that has started and not yet settled, by session ID. */
export type Tracked = ReadonlyMap<string, Started>
type LifecycleEvent = Extract<
EventSubscribeOutput,
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
>
type OpeningEvent = Extract<
LifecycleEvent,
{ readonly type: "session.compaction.started" | "session.compaction.failed" }
>
// Core reports a cancelled or interrupted compaction as a failure with one of these error types.
const Cancelled = new Set(["aborted", "compaction.interrupted"])
/**
* Whether compactions go out as the standard session updates rather than the `_meta` marker. A child compaction
* projected onto the parent session keeps the marker, since a standard update there would read as compaction of the
* parent's own context.
*/
export function usesStandardUpdates(
ctx: { readonly compaction: boolean; readonly childUpdates: boolean },
child: boolean,
) {
return ctx.compaction && (!child || ctx.childUpdates)
}
/** Tracks a lifecycle event and returns the updates it sends. */
export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean) {
const sessionID = event.data.sessionID
if (event.type === "session.compaction.started") {
const started = open(event)
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standard)] }
}
const current = tracked.get(sessionID)
const remaining = new Map(tracked)
remaining.delete(sessionID)
if (event.type === "session.compaction.ended") {
if (!current) return { tracked: remaining, updates: [] }
const completed: Compaction = {
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
}
return { tracked: remaining, updates: [update(completed, standard)] }
}
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet. A live
// standard compaction still opens before it settles.
const started = current ?? open(event)
const failed: Compaction = { ...started, status: "failed", reason: event.data.reason, error: event.data.error }
return {
tracked: remaining,
updates: [...(current || !standard ? [] : [update(started, standard)]), update(failed, standard)],
}
}
/** A summary chunk for the session's tracked compaction, if it has one and uses standard updates. */
export function chunk(started: Started | undefined, text: string, standard: boolean): SessionUpdate | undefined {
if (!started || !standard) return undefined
return { sessionUpdate: "compaction_summary_chunk", compactionId: started.messageId, content: { type: "text", text } }
}
/** Settles a compaction a cancelled turn left open the way core settles a cancelled one. */
export function abandon(started: Started, standard: boolean) {
return update({ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } }, standard)
}
/** A settled compaction as one terminal update. A running one has no live turn on this connection to settle it. */
export function replay(message: Extract<SessionMessageInfo, { type: "compaction" }>, standard: boolean) {
if (message.status === "running") return undefined
const base = { messageId: message.id, reason: message.reason }
return update(
message.status === "failed"
? { ...base, status: "failed", error: message.error }
: { ...base, status: "completed", summary: message.summary },
standard,
)
}
function open(event: OpeningEvent): Started {
return {
status: "started",
messageId: event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
reason: event.data.reason,
}
}
function update(compaction: Compaction, standard: boolean): SessionUpdate {
if (!standard) {
const marker = {
status: compaction.status,
messageId: compaction.messageId,
reason: compaction.reason,
...(compaction.status === "failed" ? { error: compaction.error } : {}),
}
return { sessionUpdate: "session_info_update", _meta: { [MarkerMeta]: marker } }
}
const compactionId = compaction.messageId
if (compaction.status === "started")
return { sessionUpdate: "compaction_update", compactionId, status: "in_progress" }
if (compaction.status === "completed") {
const summary = compaction.summary ? [{ type: "text" as const, text: compaction.summary }] : null
return { sessionUpdate: "compaction_update", compactionId, status: "completed", summary }
}
if (Cancelled.has(compaction.error.type))
return { sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null }
return {
sessionUpdate: "compaction_update",
compactionId,
status: "failed",
summary: null,
error: errorMessage(compaction.error),
}
}
// Core reports a defect as `compaction.failed` with the pretty-printed cause, whose stack trace spans several lines.
function errorMessage(error: SessionStructuredError) {
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
return error.message
}
export * as ACPCompaction from "./compaction"
+11 -23
View File
@@ -24,28 +24,13 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
}
return [{ type: "text", text: block.text }]
}
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "image": {
const url = block.data ? `data:${block.mimeType};base64,${block.data}` : block.uri
if (!url) return []
const filename = filenameFromUri(block.uri ?? undefined) ?? "image"
if (url.startsWith("data:")) return [{ type: "file", url, filename, mime: block.mimeType }]
return [resourceLinkToPart({ uri: url, name: filename, mimeType: block.mimeType })]
}
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
@@ -180,7 +165,10 @@ export function linkReference(name: string | undefined, uri: string): PromptPart
function filenameFromUri(uri: string | undefined): string | undefined {
if (!uri || uri.startsWith("data:")) return undefined
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
if (URL.canParse(uri)) {
const url = new URL(uri)
return path.basename((url.protocol === "zed:" && url.searchParams.get("path")) || url.pathname) || undefined
}
return path.basename(uri) || undefined
}
+30 -9
View File
@@ -16,8 +16,9 @@ type InputField = Exclude<Form.Field, Form.ExternalField>
type SelectField = Form.StringField | Form.MultiselectField
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
const QuestionKind = "question"
const ElicitedKind = Schema.Struct({ kind: Schema.Literals([QuestionKind, "websearch.provider"]) })
const Credential = /password|passphrase|secret|token|api[_ -]?key|credential|private[_ -]?key/i
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
type Input = {
@@ -57,9 +58,12 @@ export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: In
)
})
export const UnshownQuestionMessage =
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
/** Cancels a form, interrupting its session when the server can't cancel it. */
export function cancel(client: OpenCodeClient, form: AskedForm) {
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
function cancel(client: OpenCodeClient, form: AskedForm, message?: string) {
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id, message })).pipe(
Effect.catch(() =>
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
),
@@ -68,15 +72,16 @@ export function cancel(client: OpenCodeClient, form: AskedForm) {
/**
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
* elicitation, the form is not from an allowed flow or has a field whose key, title, description, or option label
* looks like a credential, or ACP can't represent it faithfully. Unrepresentable forms have `external` fields, `when`
* conditions, a hidden required field without a default, a default outside a field's options, or a free-text answer
* alongside options that must also satisfy `required` or item bounds across both inputs. Hidden fields are not asked
* and answer with their default.
*/
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
if (!capabilities.formElicitation) return undefined
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
if (form.fields.some((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
if (form.fields.some(credentialLike)) return undefined
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
@@ -90,6 +95,14 @@ export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabi
}
}
/**
* Cancels a form that `requestedSchema` won't show. A question is cancelled with a message the question tool returns
* to the model, so the turn continues instead of ending as interrupted; other forms are cancelled silently.
*/
export function cancelUnshown(client: OpenCodeClient, form: AskedForm) {
return cancel(client, form, form.metadata?.kind === QuestionKind ? UnshownQuestionMessage : undefined)
}
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
if (response.action !== "accept") return undefined
@@ -137,6 +150,14 @@ function settle(evaluate: () => Promise<void>) {
)
}
function credentialLike(field: Form.Field) {
const labels =
field.type === "string" || field.type === "multiselect" ? (field.options ?? []).map((option) => option.label) : []
return [field.key, field.title, field.description, ...labels].some(
(text) => text !== undefined && Credential.test(text),
)
}
function representable(field: InputField, keys: ReadonlySet<string>) {
if (field.type !== "string" && field.type !== "multiselect") return true
if (!hasOptions(field)) return true
+68 -51
View File
@@ -1,13 +1,17 @@
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
import { FileDiff } from "@opencode/schema/file-diff"
import { Patch } from "@opencode/util/patch"
import { Cause, Effect } from "effect"
import { applyPatch } from "diff"
import { Cause, Effect, Option, Schema } from "effect"
import type { ACPConnection } from "./connection"
import { ACPPromise } from "./promise"
import { ACPTranslate } from "./translate"
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Tool = { readonly name: string; readonly input: ToolInput }
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
type Preview = ReturnType<typeof diff>
type Input = {
readonly client: OpenCodeClient
@@ -17,8 +21,7 @@ type Input = {
readonly clientSessionID: string
readonly cwd: string
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
readonly child?: ACPTranslate.ChildSession
}
const options: PermissionOption[] = [
@@ -27,6 +30,8 @@ const options: PermissionOption[] = [
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
/**
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
@@ -45,23 +50,28 @@ export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Inp
const ask = Effect.fnUntraced(function* (input: Input) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
Effect.orElseSucceed((): Preview[] => []),
)
const toolCallID = input.tool?.id ?? input.event.data.id
const toolCall = pendingToolCall({
toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.child?.title, permissionTitle(toolName, toolInput, previews)),
},
cwd: input.cwd,
})
const result = yield* input.connection.requestPermission({
sessionId: input.clientSessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
...toolCall,
rawInput: input.tool ? toolCall.rawInput : undefined,
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
...(previews.length > 0 ? { content: previews } : {}),
...(input.child ? { _meta: ACPTranslate.childSessionMeta(input.child) } : {}),
},
options,
})
@@ -81,29 +91,33 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
return `${prefix}: ${title}`
}
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
// Core sends the edit and write previews as file diffs; a diff that no longer applies to the file gets no preview.
// The patch tool's diffs have their common indentation trimmed for display, so its previews come from its own hunks.
const permissionPreviews = Effect.fnUntraced(function* (
toolName: string,
input: ToolInput,
metadata: ToolInput | undefined,
cwd: string,
) {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
const file = filePath(input)
if (!file) return []
const path = absolutePath(file, cwd)
if (tool === "write") {
const content = stringValue(input.content)
if (content === undefined) return []
const oldText = yield* readText(path)
return [diff(path, oldText, content)]
}
if (tool !== "edit") return []
const oldString = stringValue(input.oldString)
const newString = stringValue(input.newString)
if (oldString === undefined || newString === undefined) return []
const oldText = yield* readText(path)
const newText =
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
return [diff(path, oldText, newText)]
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
const previews = yield* Effect.forEach(
files,
(file) =>
Effect.gen(function* () {
const path = absolutePath(file.file, cwd)
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
const newText = applyPatch(oldText ?? "", file.patch)
return newText === false ? [] : [diff(path, oldText, newText)]
}),
{ concurrency: "unbounded" },
)
return previews.flat()
})
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
// Patch.derive throws when a hunk does not match the current file, and a read can fail; the patch then gets no
// previews.
function patchPreviews(input: ToolInput, cwd: string) {
return Effect.forEach(
patchHunks(input),
@@ -112,22 +126,22 @@ function patchPreviews(input: ToolInput, cwd: string) {
const path = absolutePath(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return diff(path, "", newText)
return diff(path, null, newText)
}
const oldText = yield* readText(path)
const oldText = yield* Effect.tryPromise(() => Bun.file(path).text())
if (hunk.type === "delete") return diff(path, oldText, "")
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
}),
{ concurrency: "unbounded" },
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
)
}
function diff(path: string, oldText: string, newText: string): ToolCallContent {
return { type: "diff", path, oldText, newText }
function diff(path: string, oldText: string | null, newText: string) {
return { type: "diff" as const, path, oldText, newText }
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
if (previews.length > 1) return `${previews.length} files`
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
@@ -144,26 +158,29 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
return filePath(input) ?? previews[0]?.path
default:
return undefined
}
}
// Only these actions ask with path resources; an external directory asks for `<dir>/*`, located at the directory.
// Core's wildcard matching treats only `*` and `?` as special.
function permissionLocations(
toolName: string,
input: ToolInput,
resources: ReadonlyArray<string>,
ask: PermissionEvent["data"],
cwd: string,
): ToolCallLocation[] {
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0) return locations
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
const paths = ask.resources.flatMap((resource) => {
const path = resource.endsWith("/*") ? resource.slice(0, -2) : resource
return path && !/[*?]/.test(path) ? [absolutePath(path, cwd)] : []
})
return Array.from(new Set(paths), (path) => ({ path }))
}
// A missing file previews as empty.
function readText(path: string) {
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
}
const PathActions = new Set(["read", "edit", "external_directory"])
export * as ACPPermission from "./permission"
+26 -8
View File
@@ -53,7 +53,11 @@ export const AuthMethodID = "opencode-login"
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
/** What the client advertised in `initialize`. */
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
export type Capabilities = {
readonly childSessionUpdates: boolean
readonly formElicitation: boolean
readonly compaction: boolean
}
export interface Interface {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
@@ -128,18 +132,24 @@ export function make(input: {
return session
})
const replay = (attached: Attached) =>
Stream.paginate(undefined, (cursor: string | undefined) =>
const replay = Effect.fnUntraced(function* (attached: Attached) {
const capabilities = yield* Ref.get(input.capabilities)
yield* Stream.paginate(undefined, (cursor: string | undefined) =>
ACPPromise.promise(() =>
cursor
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
).pipe(Stream.runForEach((message) => replayMessage(attached, message, capabilities)))
})
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
const updates = ACPTranslate.replayMessage(message, attached.cwd)
const replayMessage = Effect.fnUntraced(function* (
attached: Attached,
message: SessionMessageInfo,
capabilities: Capabilities,
) {
const updates = ACPTranslate.replayMessage(message, attached.cwd, capabilities)
while (true) {
const next = yield* Effect.result(Effect.try(() => updates.next()))
if (Result.isFailure(next))
@@ -152,9 +162,11 @@ export function make(input: {
return {
initialize: Effect.fnUntraced(function* (params) {
const elicitation = params.clientCapabilities?.elicitation
const compaction = params.clientCapabilities?.session?.compaction
yield* Ref.set(input.capabilities, {
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
compaction: compaction !== undefined && compaction !== null,
})
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
@@ -196,7 +208,10 @@ export function make(input: {
}),
)
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
return yield* currentOptions(attached).pipe(
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
Effect.onError(() => input.sessions.detach(attached.id)),
)
}),
loadSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
@@ -248,7 +263,10 @@ export function make(input: {
const session = yield* getSession(params.sessionId, params.cwd)
yield* ACPDirectories.activate(input.client, session, directories)
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: yield* currentOptions(attached) }
return yield* currentOptions(attached).pipe(
Effect.map((configOptions) => ({ configOptions })),
Effect.onError(() => input.sessions.detach(attached.id)),
)
}),
closeSession: Effect.fnUntraced(function* (params) {
yield* input.turn.close(params.sessionId)
+62 -83
View File
@@ -6,17 +6,16 @@ import type {
SessionStructuredError,
TokenUsageInfo,
} from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import { ACPCompaction } from "./compaction"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import type { ACPService } from "./service"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
const RetryMeta = "opencode/retry"
const CompactionMeta = "opencode/compaction"
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
@@ -27,6 +26,8 @@ export type Context = {
readonly cwd: string
readonly start: TurnStart
readonly childUpdates: boolean
/** Whether the client advertised `session.compaction`, so compactions use the standard session updates. */
readonly compaction: boolean
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
readonly mode: "turn" | "background"
}
@@ -72,7 +73,7 @@ export type TurnState = {
readonly started: boolean
readonly tools: ReadonlyMap<string, Tool>
readonly retries: ReadonlyMap<string, RetryStatus>
readonly compactions: ReadonlyMap<string, string>
readonly compactions: ACPCompaction.Tracked
readonly children: ReadonlyMap<string, ChildSession>
readonly openChildren: ReadonlySet<string>
/** Forms asked of the client that the server has not yet answered or cancelled. */
@@ -110,18 +111,6 @@ export type Step = {
readonly terminal?: Terminal
}
type CompactionEvent = Extract<
EventSubscribeOutput,
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
>
type CompactionMarker = {
readonly status: "started" | "completed" | "failed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly error?: SessionStructuredError
}
export const initial: TurnState = {
started: false,
tools: new Map(),
@@ -207,11 +196,20 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
case "session.compaction.started":
case "session.compaction.ended":
case "session.compaction.failed": {
const compacted = compactionMarker(event, state.compactions)
return {
state: { ...state, compactions: compacted.compactions },
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
}
const applied = ACPCompaction.apply(
event,
state.compactions,
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
)
return { state: { ...state, compactions: applied.tracked }, outputs: applied.updates.flatMap(send) }
}
case "session.compaction.delta": {
const update = ACPCompaction.chunk(
state.compactions.get(eventSessionID),
event.data.text,
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
)
return { state, outputs: update ? send(update) : [] }
}
case "session.text.delta":
return {
@@ -378,29 +376,42 @@ export function response(state: TurnState, sessionID: string, terminal: Terminal
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
export function abandonTools(state: TurnState, ctx: Context): Step {
/**
* Fails the tools and cancels the session's compaction a cancelled turn left open, for when the server's wind-down
* never reports them. Child compactions are left to the consumer that follows children after the turn.
*/
export function abandon(state: TurnState, ctx: Context): Step {
const compaction = state.compactions.get(ctx.sessionID)
return {
state: { ...state, tools: new Map() },
outputs: [...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
outputs: [
...[...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
}),
}),
}),
),
),
...(compaction
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
: []),
],
}
}
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
export function* replayMessage(message: SessionMessageInfo, cwd: string): Generator<SessionUpdate> {
export function* replayMessage(
message: SessionMessageInfo,
cwd: string,
capabilities: ACPService.Capabilities,
): Generator<SessionUpdate> {
if (message.type === "user") {
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
@@ -413,14 +424,9 @@ export function* replayMessage(message: SessionMessageInfo, cwd: string): Genera
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
return
}
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
if (message.type === "compaction" && message.status !== "running") {
yield compactionUpdate({
status: message.status,
messageId: message.id,
reason: message.reason,
...(message.status === "failed" ? { error: message.error } : {}),
})
if (message.type === "compaction") {
const update = ACPCompaction.replay(message, capabilities.compaction)
if (update) yield update
return
}
if (message.type !== "assistant") return
@@ -567,40 +573,18 @@ function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
const sessionID = event.data.sessionID
if (event.type === "session.compaction.started") {
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
}
const tracked = compactions.get(sessionID)
const remaining = without(compactions, sessionID)
if (event.type === "session.compaction.ended") {
const marker: CompactionMarker | undefined = tracked
? { status: "completed", messageId: tracked, reason: event.data.reason }
: undefined
return { marker, compactions: remaining }
}
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
const marker: CompactionMarker = {
status: "failed",
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
reason: event.data.reason,
error: event.data.error,
}
return { marker, compactions: remaining }
}
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
projected._meta = { ...projected._meta, ...childSessionMeta(child) }
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
export function childSessionMeta(child: ChildSession) {
return {
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
@@ -608,11 +592,6 @@ function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
+12 -12
View File
@@ -142,7 +142,7 @@ export const make = Effect.fnUntraced(function* (input: {
clientSessionID: ctx.sessionID,
cwd: ctx.cwd,
tool: ask.tool,
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
child: ask.child,
},
Deferred.await(subscription.cancelled),
)
@@ -161,7 +161,7 @@ export const make = Effect.fnUntraced(function* (input: {
return Effect.gen(function* () {
const capabilities = yield* Ref.get(input.capabilities)
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
if (!requestedSchema) return yield* ACPElicitation.cancel(input.client, output.form)
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
const settled = yield* Deferred.make<void>()
subscription.forms.set(output.form.id, settled)
yield* Queue.offer(
@@ -251,7 +251,7 @@ export const make = Effect.fnUntraced(function* (input: {
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
// timeout. Tools and a compaction still open at the timeout are settled so the client never shows them running.
const windDown = Effect.fnUntraced(function* (
subscription: Subscription,
ctx: ACPTranslate.Context,
@@ -263,7 +263,7 @@ export const make = Effect.fnUntraced(function* (input: {
if (!(yield* Ref.get(state)).started) return
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
yield* Fiber.interrupt(events)
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
yield* Ref.set(state, abandoned.state)
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
Effect.ignore,
@@ -352,17 +352,15 @@ export const make = Effect.fnUntraced(function* (input: {
)
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
const run = Effect.fn("cli.acp.turn.run")(function* (
attached: Attached,
prompt: PreparedPrompt,
childUpdates: boolean,
) {
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
const capabilities = yield* Ref.get(input.capabilities)
const state = yield* Ref.make(ACPTranslate.initial)
const ctx: ACPTranslate.Context = {
sessionID: attached.id,
cwd: attached.cwd,
start: prompt.start,
childUpdates,
childUpdates: capabilities.childSessionUpdates,
compaction: capabilities.compaction,
mode: "turn",
}
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
@@ -373,7 +371,9 @@ export const make = Effect.fnUntraced(function* (input: {
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
const attached = yield* input.sessions.require(params.sessionId)
const catalog = yield* input.catalog.get(attached.cwd)
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
}
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
concurrency: "unbounded",
})
@@ -388,7 +388,7 @@ export const make = Effect.fnUntraced(function* (input: {
}),
)
}
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt), { uninterruptible: true })
FiberMap.setUnsafe(turns, attached.id, forked)
return Effect.succeed(forked)
})
@@ -2,30 +2,16 @@ import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sd
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { createAcpFixture, expectOk, initialize } from "./subprocess"
import { createAcpFixture, expectOk, initialize, toolCallStream } from "./subprocess"
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
function readingModel(file: () => string) {
return (request: unknown) => {
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
return toolCallStream("call_read", "read", { path: file() })
}
}
function toolCall(file: string) {
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
const chunks = [
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
{
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
usage: null,
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
]
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
}
describe("acp additional directories subprocess", () => {
test("tools read files in an additional directory without an external directory ask", async () => {
const target = { file: "" }
+420 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
@@ -7,8 +7,10 @@ import { Schema } from "effect"
import {
assistantMessage,
childCreated,
delivered,
durableEvent,
ephemeralEvent,
interrupted,
makeSession,
startSession,
startWire,
@@ -16,11 +18,14 @@ import {
succeeded,
textDelta,
turn,
type InitializeOptions,
type Wire,
} from "./wire-fixture"
const summary = "Summary of the earlier conversation"
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
const standard: InitializeOptions = { compaction: {} }
describe("acp compaction markers over the wire", () => {
test("marks a /compact turn without forwarding the summary text", async () => {
@@ -191,10 +196,397 @@ describe("acp compaction markers over the wire", () => {
})
})
describe("acp standard compaction updates over the wire", () => {
test("streams a /compact turn's summary and completes it with the full summary", async () => {
const compacted = await compactTurn(
(sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "Summary of " }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
],
standard,
)
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId: compacted.id, status: "in_progress" }),
summaryChunk(acp.sessionId, compacted.id, "Summary of "),
summaryChunk(acp.sessionId, compacted.id, "the earlier conversation"),
compaction(acp.sessionId, {
compactionId: compacted.id,
status: "completed",
summary: [{ type: "text", text: summary }],
}),
])
expect(compacted.response.stopReason).toBe("end_turn")
})
test("completes a compaction without a readable summary with a cleared summary", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: "", recent: "" }),
textDelta(sessionID, "msg_after", "after"),
stepEnded(sessionID, "msg_after"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
compaction(acp.sessionId, { compactionId, status: "completed", summary: null }),
chunk(acp.sessionId, "msg_after", "after"),
])
})
test("fails a compaction with its error and clears the partial summary", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
summaryChunk(acp.sessionId, compactionId, "partial"),
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: providerError.message }),
])
})
test("opens a compaction that fails before it starts before settling it", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.failed", {
sessionID,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.failed")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: "Nothing to compact yet" }),
])
})
test("reports a defect as a generic failure and keeps core's own failure messages", async () => {
const limit = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
const defect = { type: "compaction.failed", message: "Error: database is locked\n at run (compaction.ts:1:1)" }
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: limit }),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: defect }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const first = await events.messageID("session.compaction.started")
const second = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId: first, status: "in_progress" }),
compaction(acp.sessionId, { compactionId: first, status: "failed", summary: null, error: limit.message }),
compaction(acp.sessionId, { compactionId: second, status: "in_progress" }),
compaction(acp.sessionId, { compactionId: second, status: "failed", summary: null, error: "Compaction failed" }),
])
})
test("drops summary chunks outside an open compaction", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
ephemeralEvent("session.compaction.delta", { sessionID, text: "early" }),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "late" }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
compaction(acp.sessionId, { compactionId, status: "completed", summary: [{ type: "text", text: summary }] }),
])
})
test("cancels a compaction the server interrupts during a cancelled turn's wind-down", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
],
onInterrupt: ({ sessionID }) => [
durableEvent("session.compaction.failed", {
sessionID,
reason: "auto",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
interrupted(sessionID),
],
})
using events = await watchEvents(acp.server.url)
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_update")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
const compactionId = await events.messageID("session.compaction.started")
expect(updatesBeforeResponse(acp)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
])
})
test("cancels a compaction left open when the wind-down never reports it", async () => {
const submitted: string[] = []
await using acp = await startSession({
capabilities: standard,
cancelDrainTimeout: "50 millis",
onPrompt: ({ sessionID, id }) => {
submitted.push(id)
return [
delivered(sessionID, id),
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
]
},
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_summary_chunk")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
const compactionId = submitted[0]
expect(updatesBeforeResponse(acp)).toEqual([
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
summaryChunk(acp.sessionId, compactionId, "partial").update,
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
])
})
test("closes the marker of a compaction left open when the wind-down never reports it", async () => {
const submitted: string[] = []
await using acp = await startSession({
cancelDrainTimeout: "50 millis",
onPrompt: ({ sessionID, id }) => {
submitted.push(id)
return [
delivered(sessionID, id),
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
]
},
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
const messageId = submitted[0]
expect(updatesBeforeResponse(acp)).toEqual([
marker(acp.sessionId, { status: "started", messageId, reason: "manual" }).update,
marker(acp.sessionId, {
status: "failed",
messageId,
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
}).update,
])
})
test("keeps child compactions projected onto the parent as markers", async () => {
await using acp = await startSession({
capabilities: standard,
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
durableEvent("session.compaction.ended", {
sessionID: "ses_child",
reason: "auto",
text: summary,
recent: "",
}),
succeeded("ses_child"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const messageId = await events.messageID("session.compaction.started")
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
])
})
test("sends child compactions as standard updates through child session updates", async () => {
await using acp = await startSession({
capabilities: { ...standard, childSessionUpdates: true },
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
durableEvent("session.compaction.ended", {
sessionID: "ses_child",
reason: "auto",
text: summary,
recent: "",
}),
succeeded("ses_child"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const compactionId = await events.messageID("session.compaction.started")
const child = { "opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" } }
expect(acp.childUpdates.flatMap((item) => (item.type === "update" ? [item.update] : []))).toEqual([
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress", _meta: child },
{
sessionUpdate: "compaction_update",
compactionId,
status: "completed",
summary: [{ type: "text", text: summary }],
_meta: child,
},
])
expect(turnUpdates(acp.updates)).toEqual([])
})
test("replays settled compactions as terminal updates at their position on session/load", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
acp.server.messages.set("ses_compacted", [
...compactedHistory().slice(0, 3),
{
id: "msg_compaction_cancelled",
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
time: { created: 3 },
},
{
id: "msg_compaction_native",
type: "compaction",
status: "completed",
reason: "auto",
summary: "",
recent: "",
time: { created: 3 },
},
...compactedHistory().slice(3),
])
await acp.initialize(standard)
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: "ses_compacted",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
compaction("ses_compacted", {
compactionId: "msg_compaction_failed",
status: "failed",
summary: null,
error: providerError.message,
}),
compaction("ses_compacted", {
compactionId: "msg_compaction",
status: "completed",
summary: [{ type: "text", text: summary }],
}),
compaction("ses_compacted", { compactionId: "msg_compaction_cancelled", status: "cancelled", summary: null }),
compaction("ses_compacted", { compactionId: "msg_compaction_native", status: "completed", summary: null }),
chunk("ses_compacted", "msg_after", "after"),
])
})
test("treats a null compaction capability as unsupported", async () => {
const compacted = await compactTurn(
(sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
],
{ compaction: null },
)
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
])
})
})
// Holds the compact response so the test can publish the turn's events while the request is in flight.
async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEvent[]) {
async function compactTurn(
events: (sessionID: string, id: string) => OpenCodeEvent[],
capabilities?: InitializeOptions,
) {
const held = Promise.withResolvers<Response>()
const acp = await startSession({ fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined) })
const acp = await startSession({
capabilities,
fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined),
})
const response = acp.prompt(acp.sessionId, "/compact")
const request = await acp.until(
() => acp.server.requests.find((item) => item.path.endsWith("/compact")),
@@ -227,10 +619,35 @@ function marker(sessionId: string, value: Record<string, unknown>): SessionNotif
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
}
function compaction(sessionId: string, update: CompactionUpdate): SessionNotification {
return { sessionId, update: { sessionUpdate: "compaction_update", ...update } }
}
function summaryChunk(sessionId: string, compactionId: string, text: string): SessionNotification {
return {
sessionId,
update: { sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text } },
}
}
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
}
const isCancelledResponse = Schema.is(
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
)
// Session updates the client received before the cancelled prompt response.
function updatesBeforeResponse(acp: Wire) {
const response = acp.received.findIndex(isCancelledResponse)
expect(response).toBeGreaterThan(-1)
const count = acp.received
.slice(0, response)
.filter((message) => "method" in message && message.method === "session/update").length
return turnUpdates(acp.updates.slice(0, count)).map((item) => item.update)
}
function turnUpdates(updates: readonly SessionNotification[]) {
return updates.filter(
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
+35 -9
View File
@@ -45,22 +45,48 @@ describe("acp content conversion", () => {
])
})
test("image block with http URI becomes a file part", () => {
test("image block with only a data URL URI becomes a file part", () => {
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "data:image/png;base64,AAAA" }),
).toEqual([{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" }])
})
test("image block with only an http URI becomes a markdown link", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
uri: "https://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
).toEqual([{ type: "text", text: "[photo.jpg](https://example.com/assets/photo.jpg)" }])
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/" }),
).toEqual([{ type: "text", text: "[image](https://example.com/)" }])
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "http://example.com/a.png" }),
).toEqual([{ type: "text", text: "[a.png](http://example.com/a.png)" }])
})
test("image block with only a URI of another scheme or a bare path becomes a markdown link", () => {
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "ftp://host/a.png" })).toEqual([
{ type: "text", text: "[a.png](ftp://host/a.png)" },
])
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "assets/b.png" })).toEqual([
{ type: "text", text: "[b.png](assets/b.png)" },
])
})
test("image block with only a file URI becomes a file part", () => {
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }),
).toEqual([{ type: "file", url: "file:///tmp/screenshot.png", filename: "screenshot.png", mime: "image/png" }])
})
test("image block with only a zed path becomes a file part named after the path", () => {
expect(
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "zed://file?path=/tmp/shot.png" }),
).toEqual([{ type: "file", url: pathToFileURL("/tmp/shot.png").href, filename: "shot.png", mime: "image/png" }])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
+60 -1
View File
@@ -45,7 +45,7 @@ const questions = (sessionID: string, id = "frm_question", tool = "call_question
},
})
const capable = { childSessionUpdates: false, formElicitation: true }
const capable = { childSessionUpdates: false, formElicitation: true, compaction: false }
const form = (
fields: ACPElicitation.AskedForm["fields"],
@@ -191,6 +191,8 @@ describe("acp elicitation mapping", () => {
[{ key: "api_key", type: "string" }],
[{ key: "q0", title: "GitHub token", type: "string" }],
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
[{ key: "q0", title: "Setup", description: "Paste your API key", type: "string" }],
[{ key: "q0", title: "Setup", type: "string", options: [{ value: "a", label: "Use my access token" }] }],
]
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
credentials.map(() => undefined),
@@ -332,6 +334,7 @@ describe("acp elicitation over the wire", () => {
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(ids.length)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
expect(acp.server.cancelledForms.map((item) => item.message)).toEqual(ids.map(() => undefined))
expect(acp.server.repliedForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
@@ -359,6 +362,62 @@ describe("acp elicitation over the wire", () => {
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
})
test("cancels a question that looks like it asks for a credential with a message for the model", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_token",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
fields: [
{
key: "q0",
title: "Setup",
description: "Paste your API key",
type: "string",
options: [{ value: "Later", label: "Later" }],
custom: true,
},
],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_token", message: ACPElicitation.UnshownQuestionMessage },
])
})
test("cancels other forms it cannot show without a message", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_websearch",
sessionID,
title: "Web search provider",
metadata: { kind: "websearch.provider" },
fields: [{ key: "provider", type: "string", options: [{ value: "exa", label: "Exa" }] }],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_websearch" }])
})
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
+198 -23
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import { createTwoFilesPatch } from "diff"
import { Cause } from "effect"
import fs from "node:fs/promises"
import path from "node:path"
@@ -31,6 +32,10 @@ describe("acp permissions over the wire", () => {
turn(
sessionID,
id,
toolStarted(sessionID, "call_once", "shell"),
toolCalled(sessionID, "call_once", { command: "printf hello" }),
toolStarted(sessionID, "call_always", "read"),
toolCalled(sessionID, "call_always", { path: "/workspace/file.ts" }),
permissionAsked(sessionID, "perm_once", {
action: "shell",
metadata: { command: "printf hello" },
@@ -101,8 +106,84 @@ describe("acp permissions over the wire", () => {
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "/tmp/outside",
locations: [{ path: "/tmp/outside/a.ts" }],
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
})
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
})
test("locates path resources without glob patterns when the ask has no tool locations", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_external", {
action: "external_directory",
resources: ["/tmp/outside/*", "/tmp/other/*", "/tmp/outside/*"],
metadata: {},
}),
permissionAsked(sessionID, "perm_read", {
action: "read",
resources: ["src/[slug].ts", "src/{a,b}.ts", "**/*.ts", "src/?.ts"],
}),
permissionAsked(sessionID, "perm_search", { action: "websearch", resources: ["acp spec"] }),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.locations)).toEqual([
[{ path: "/tmp/outside" }, { path: "/tmp/other" }],
[{ path: path.resolve("/workspace", "src/[slug].ts") }, { path: path.resolve("/workspace", "src/{a,b}.ts") }],
[],
])
})
test("asks under the permission ID when ACP does not track the asking tool call", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_untracked", {
source: { type: "tool", messageID: "msg_tools", id: "call_untracked" },
}),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_untracked"])
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
})
test("asks about a tracked tool call with its own input", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_read", "read"),
toolCalled(sessionID, "call_read", { path: "/workspace/file.ts" }),
permissionAsked(sessionID, "perm_read", {
action: "read",
metadata: { files: ["file.ts"] },
source: { type: "tool", messageID: "msg_read", id: "call_read" },
}),
),
permission: allowOnce,
})
await acp.prompt(acp.sessionId, "hello")
const running = acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call_update" && item.update.status === "in_progress"
? [item.update.rawInput]
: [],
)
expect(running).toEqual([{ path: "/workspace/file.ts" }])
expect(acp.permissions[0]?.toolCall).toMatchObject({ toolCallId: "call_read", rawInput: running[0] })
})
test("routes foreground child permissions through the parent ACP session", async () => {
@@ -113,9 +194,10 @@ describe("acp permissions over the wire", () => {
id,
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
toolStarted("ses_child", "call_child", "read"),
toolCalled("ses_child", "call_child", { path: "/workspace/child.ts" }),
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
succeeded("ses_child"),
@@ -125,11 +207,17 @@ describe("acp permissions over the wire", () => {
await acp.prompt(acp.sessionId, "hello")
const childMeta = {
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Review code" },
}
expect(acp.permissions).toHaveLength(1)
expect(acp.permissions[0]).toMatchObject({
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts", _meta: childMeta },
})
expect(
acp.updates.flatMap((item) => (item.update.sessionUpdate === "tool_call" ? [item.update._meta] : [])),
).toEqual([childMeta])
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
})
@@ -149,10 +237,17 @@ describe("acp permissions over the wire", () => {
)
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
const childMeta = {
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
}
expect(acp.permissions).toMatchObject([
{
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
toolCall: {
toolCallId: "ses_background:perm_background",
title: "Research: /workspace/notes.md",
_meta: childMeta,
},
},
])
expect(acp.server.replies).toEqual([
@@ -353,27 +448,24 @@ describe("acp permissions over the wire", () => {
})
describe("acp edit previews over the wire", () => {
test("previews edits during approval", async () => {
test("previews core's edit diff during approval", async () => {
await using dir = await tmpdir()
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "before")
await fs.writeFile(file, "one\r\ntwo\r\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
permissionAsked(sessionID, "perm_edit", {
action: "edit",
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
],
onPermissionReply: async ({ sessionID }) => {
await fs.writeFile(file, "after")
return [
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
succeeded(sessionID),
]
},
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" }),
permissionAsked(sessionID, "perm_edit", {
action: "edit",
resources: ["file.ts"],
metadata: { files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
}),
),
permission: allowOnce,
})
await acp.initialize()
@@ -385,7 +477,7 @@ describe("acp edit previews over the wire", () => {
title: "file.ts",
kind: "edit",
locations: [{ path: file }],
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
content: [{ type: "diff", path: file, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
})
})
@@ -445,6 +537,85 @@ describe("acp edit previews over the wire", () => {
})
})
test("previews added files as new and deleted files as empty", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "gone.ts"), "gone\n")
const patchText = ["*** Begin Patch", "*** Add File: added.ts", "+one", "*** End Patch"].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_write", "write"),
toolCalled(sessionID, "call_write", { path: "written.ts", content: "two\n" }),
permissionAsked(sessionID, "perm_write", {
action: "edit",
metadata: { files: [fileDiff("written.ts", "", "two\n", "added")] },
source: { type: "tool", messageID: "msg_write", id: "call_write" },
}),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
permissionAsked(sessionID, "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
permissionAsked(sessionID, "perm_delete", {
action: "edit",
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
}),
),
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
[{ type: "diff", path: path.join(dir.path, "written.ts"), oldText: null, newText: "two\n" }],
[{ type: "diff", path: path.join(dir.path, "added.ts"), oldText: null, newText: "one\n" }],
[{ type: "diff", path: path.join(dir.path, "gone.ts"), oldText: "gone\n", newText: "" }],
])
})
test("asks without previews when core's diff no longer applies or the file cannot be read", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "file.ts"), "changed\n")
await fs.mkdir(path.join(dir.path, "folder"))
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
permissionAsked(sessionID, "perm_stale", {
action: "edit",
resources: ["file.ts"],
metadata: { files: [fileDiff("file.ts", "one\n", "two\n")] },
}),
permissionAsked(sessionID, "perm_folder", {
action: "edit",
resources: ["folder"],
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
}),
),
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions.map((request) => request.toolCall)).toMatchObject([
{ kind: "edit", locations: [{ path: path.join(dir.path, "file.ts") }] },
{ kind: "edit", locations: [{ path: path.join(dir.path, "folder") }] },
])
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([undefined, undefined])
expect(decisions(acp)).toEqual([
["perm_stale", "once"],
["perm_folder", "once"],
])
})
test("asks without previews when a patch does not apply to the current file", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
@@ -563,3 +734,7 @@ describe("acp edit previews over the wire", () => {
function decisions(acp: Wire) {
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
}
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
}
@@ -7,7 +7,7 @@ import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("acp prompt content subprocess", () => {
test("accepts embedded text resource image and file resource link prompt content", async () => {
test("accepts embedded text resource, inline and remote image, and file resource link prompt content", async () => {
await using fixture = await createAcpFixture()
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
const acp = fixture.spawn()
@@ -41,6 +41,16 @@ describe("acp prompt content subprocess", () => {
}),
)
const remote = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this remote image." },
{ type: "image", mimeType: "image/png", data: "", uri: "https://example.com/remote.png" },
],
}),
)
const linked = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
@@ -70,8 +80,9 @@ describe("acp prompt content subprocess", () => {
}),
)
expect(remote.stopReason).toBe("end_turn")
expect(linked.stopReason).toBe("end_turn")
expect(missing.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(5)
}, 60_000)
})
+38
View File
@@ -142,6 +142,44 @@ describe("acp prompt turns over the wire", () => {
])
})
test("attaches local image links and references remote or unreadable ones in place", async () => {
await using dir = await tmpdir()
const local = pathToFileURL(path.join(dir.path, "local.png")).href
const missing = pathToFileURL(path.join(dir.path, "missing.png")).href
await Bun.write(path.join(dir.path, "local.png"), "png")
await using acp = await startSession()
const response = await acp.prompt(acp.sessionId, [
{ type: "text", text: "compare" },
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
{ type: "image", data: "", mimeType: "image/png", uri: local },
{ type: "image", data: "", mimeType: "image/png", uri: missing },
])
expect(response.stopReason).toBe("end_turn")
expect(acp.server.submissions).toEqual([
expect.objectContaining({
kind: "prompt",
text: `compare\n[remote.png](https://example.com/remote.png)\n[missing.png](${missing})`,
files: [{ uri: local, name: "local.png" }],
}),
])
})
test("rejects an image with neither data nor uri as invalid params", async () => {
await using acp = await startSession()
expect(
await rpcError(
acp.prompt(acp.sessionId, [
{ type: "text", text: "look" },
{ type: "image", data: "", mimeType: "image/png" },
]),
),
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
expect(acp.server.submissions).toEqual([])
})
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
await using acp = await startSession({
@@ -0,0 +1,52 @@
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { Option, Schema } from "effect"
import { ACPElicitation } from "../../src/acp/elicitation"
import { createAcpFixture, expectOk, initialize, newSession, toolCallStream } from "./subprocess"
const ChatRequest = Schema.Struct({
messages: Schema.Array(
Schema.Struct({ role: Schema.String, tool_call_id: Schema.optional(Schema.String), content: Schema.Unknown }),
),
})
// The first completion asks a question; the follow-up completion ends the turn.
function askingModel(request: unknown) {
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
return toolCallStream("call_question", "question", {
questions: [
{
header: "Runtime",
question: "Which runtime?",
options: [
{ label: "Bun", description: "Fast" },
{ label: "Node", description: "Stable" },
],
},
],
})
}
describe("acp question subprocess", () => {
test("a question the client cannot show returns to the model and the turn continues", async () => {
await using fixture = await createAcpFixture({ respond: askingModel })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const result = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "ask me something" }],
}),
)
expect(result.stopReason).toBe("end_turn")
const results = fixture.llm.requests
.flatMap((request) => Option.toArray(Schema.decodeUnknownOption(ChatRequest)(request)))
.flatMap((request) => request.messages)
.filter((message) => message.role === "tool" && message.tool_call_id === "call_question")
expect(results).toHaveLength(1)
expect(String(results[0]?.content)).toContain(ACPElicitation.UnshownQuestionMessage)
}, 60_000)
})
+33
View File
@@ -205,6 +205,39 @@ describe("acp session lifecycle over the wire", () => {
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded" } })
})
test("detaches sessions whose new or resume fails after attaching", async () => {
// A model without a name forces a crash building config options after attach.
await using acp = await startWire({
fetch: (request) =>
request.path === "/api/model"
? Response.json({
location: { directory: "/workspace", project: { id: "global", directory: "/workspace" } },
data: [testModel, { ...secondModel, name: null }],
})
: undefined,
})
acp.server.sessions.set("ses_resumed", makeSession("ses_resumed"))
await acp.initialize()
const existing = new Set(acp.server.sessions.keys())
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603 })
const created = acp.server.sessions.keys().find((id) => !existing.has(id))
if (!created) throw new Error("session/new did not create a server session")
expect(
await rpcError(acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resumed", mcpServers: [] })),
).toMatchObject({ code: -32603 })
const failed = [created, "ses_resumed"]
expect(
await Promise.all(
failed.map((sessionId) =>
rpcError(acp.request("session/set_config_option", { sessionId, configId: "mode", value: "plan" })),
),
),
).toMatchObject(failed.map((sessionId) => ({ code: -32602, data: { sessionId } })))
expect(acp.server.sessions.has(created)).toBe(true)
})
test("lists server-backed pages for the requested cwd", async () => {
await using acp = await startWire()
Array.from({ length: 101 }, (_, index) =>
+24
View File
@@ -161,6 +161,30 @@ export function expectOk<T>(response: JsonRpcResponse<T>) {
return response.result
}
/** A streamed completion that calls one tool. */
export function toolCallStream(id: string, name: string, input: unknown) {
const chunks = [
{
choices: [
{
delta: { role: "assistant", tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
finish_reason: null,
},
],
usage: null,
},
{
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(input) } }] } }],
usage: null,
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
]
return new Response(`${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`, {
headers: { "content-type": "text/event-stream" },
})
}
function verifierConfig(llmUrl: string, skills?: string) {
const model = {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
+3 -2
View File
@@ -22,6 +22,7 @@ const ctx: ACPTranslate.Context = {
cwd: "/workspace",
start: { type: "input", id: "msg_input" },
childUpdates: false,
compaction: false,
mode: "turn",
}
@@ -164,7 +165,7 @@ describe("acp turn translation", () => {
failed("ses_child", { type: "aborted", message: "interrupted" }),
)
const abandoned = ACPTranslate.abandonTools(result.state, ctx)
const abandoned = ACPTranslate.abandon(result.state, ctx)
expect(abandoned.state.tools.size).toBe(0)
expect(updates(abandoned.outputs)).toEqual([
@@ -177,6 +178,6 @@ describe("acp turn translation", () => {
}),
expect.objectContaining({ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" }),
])
expect(ACPTranslate.abandonTools(abandoned.state, ctx).outputs).toEqual([])
expect(ACPTranslate.abandon(abandoned.state, ctx).outputs).toEqual([])
})
})
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { resolve } from "node:path"
import { ACPElicitation } from "../../src/acp/elicitation"
import {
assistantMessage,
childCreated,
@@ -363,7 +364,9 @@ describe("acp turn events over the wire", () => {
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
expect(acp.server.cancelledForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_question", message: ACPElicitation.UnshownQuestionMessage },
])
expect(acp.elicitations).toEqual([])
})
+8 -4
View File
@@ -8,6 +8,7 @@ import {
type AgentRequestParamsByMethod,
type AgentRequestResponsesByMethod,
type AnyMessage,
type CompactionCapabilities,
type ContentBlock,
type CreateElicitationRequest,
type CreateElicitationResponse,
@@ -164,6 +165,7 @@ export type InitializeOptions = {
readonly childSessionUpdates?: boolean
readonly terminalAuth?: boolean
readonly elicitation?: boolean
readonly compaction?: CompactionCapabilities | null
}
export const testModel = {
@@ -370,6 +372,7 @@ export function permissionAsked(
id: string,
input: {
readonly action?: string
readonly resources?: EventData<"permission.asked">["resources"]
readonly metadata?: EventData<"permission.asked">["metadata"]
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
@@ -378,7 +381,7 @@ export function permissionAsked(
id,
sessionID,
action: input.action ?? "shell",
resources: ["*"],
resources: input.resources ?? ["*"],
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
@@ -490,6 +493,7 @@ export async function startWire(options: WireOptions = {}) {
clientCapabilities: {
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
...(capabilities.elicitation ? { elicitation: { form: {} } } : {}),
...(capabilities.compaction !== undefined ? { session: { compaction: capabilities.compaction } } : {}),
_meta: {
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
@@ -565,7 +569,7 @@ function startServer(options: WireOptions, changed: () => void) {
const selections: Selection[] = []
const interrupts: string[] = []
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string; readonly message?: string }> = []
const repliedForms: FormReply[] = []
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
const fake = {
@@ -827,8 +831,8 @@ function startServer(options: WireOptions, changed: () => void) {
}),
},
"/api/session/:sessionID/form/:formID": {
DELETE: route(async (req) => {
const form = { sessionID: req.params.sessionID, formID: req.params.formID }
DELETE: route(async (req, query) => {
const form = { sessionID: req.params.sessionID, formID: req.params.formID, message: query.message }
fake.cancelledForms.push(form)
await emit(options.onFormCancel?.(form))
return noContent()
+8
View File
@@ -2270,6 +2270,13 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type VcsInitInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}
export type VcsInitOutput = void
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
export type VcsGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type VcsGetOutput = { readonly location: Location.PublicRef; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
@@ -2300,6 +2307,7 @@ export type VcsDiffOutput = { readonly location: Location.PublicRef; readonly da
export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect<VcsDiffOutput, E>
export interface VcsApi<E = never> {
readonly init: VcsInitOperation<E>
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
@@ -242,6 +242,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -1481,6 +1483,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointVcsInit = (raw: RawClient["server.vcs"]) => (input?: VcsInitInput) =>
preserveEffect<VcsInitOutput>()(
raw["vcs.init"]({ query: { location: input?.["location"], provider: input?.["provider"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1511,6 +1520,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
init: EndpointVcsInit(raw),
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
@@ -240,6 +240,8 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsInitInput,
VcsInitOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -2040,6 +2042,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
init: (input?: VcsInitInput, requestOptions?: RequestOptions) =>
request<VcsInitOutput>(
{
method: "POST",
path: `/api/vcs/init`,
query: { location: input?.["location"], provider: input?.["provider"] },
successStatus: 204,
declaredStatuses: [400, 401, 409, 501, 503],
empty: true,
},
requestOptions,
),
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
@@ -2742,6 +2742,14 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "WorktreeError"
export type VcsInitNotSupportedError = {
readonly _tag: "VcsInitNotSupportedError"
readonly providerID: string
readonly message: string
}
export const isVcsInitNotSupportedError = (value: unknown): value is VcsInitNotSupportedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "VcsInitNotSupportedError"
export type ServerInfoOutput = ServerInfo
export type ServerPairOutput = PairingCode
@@ -6495,6 +6503,19 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type VcsInitInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}["location"]
readonly provider?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly provider?: string | undefined
}["provider"]
}
export type VcsInitOutput = void
export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+19 -1
View File
@@ -1,6 +1,8 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { Entry } from "@opencode/schema/config"
import type { ConfigFormatter } from "@opencode/schema/config/formatter"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
@@ -25,7 +27,7 @@ export const Plugin = define({
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
yield* formatter.transform((editor) => {
const configured = Config.latest(loaded.entries, "formatter")
const configured = resolve(loaded.entries)
if (!configured) return
const builtIns = make({
directory: location.directory,
@@ -56,3 +58,19 @@ export const Plugin = define({
})
}),
})
// Documents apply from lowest to highest priority. A boolean resets everything before it; an object
// merges each named formatter's supplied fields over the same name from earlier objects.
function resolve(entries: readonly Entry[]) {
return entries.reduce<boolean | Record<string, ConfigFormatter.Entry> | undefined>((result, entry) => {
if (entry.type !== "document" || entry.info.formatter === undefined) return result
if (typeof entry.info.formatter === "boolean") return entry.info.formatter
return Object.entries(entry.info.formatter).reduce(
(merged, [name, item]) => ({
...merged,
[name]: { ...merged[name], ...item, environment: { ...merged[name]?.environment, ...item.environment } },
}),
typeof result === "object" ? result : {},
)
}, undefined)
}
+11 -4
View File
@@ -10,6 +10,7 @@ const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
"disable-model-invocation": Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
@@ -22,12 +23,16 @@ const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = (metadata as Record<string, unknown>)[key]
return booleanValue((metadata as Record<string, unknown>)[key])
}
const booleanValue = (value: unknown) => {
if (typeof value === "boolean") return value
if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
if (["true", "yes", "on", "1"].includes(normalized)) return true
if (["false", "no", "off", "0"].includes(normalized)) return false
return undefined
}
@@ -41,7 +46,9 @@ export function parse(directory: string, filepath: string, content: string): Par
path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md"
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
const opencodeAutoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
const disableModelInvocation = booleanValue(frontmatter["disable-model-invocation"])
const autoinvoke = opencodeAutoinvoke ?? (disableModelInvocation ? false : undefined)
return {
_tag: "Parsed",
skill: {
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
+1 -35
View File
@@ -1,13 +1,12 @@
export * as PluginHost from "./host.js"
import { Plugin } from "@opencode/plugin/effect"
import { SessionList } from "@opencode/schema/session-list"
import type { IntegrationMethodRegistration } from "@opencode/plugin/effect/integration"
import { EventManifest } from "@opencode/schema/event-manifest"
import type { Event } from "@opencode/schema/event"
import { ServerConfig } from "@opencode/schema/mcp"
import { App } from "../app.js"
import { DateTime, Effect, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
import { AISDK } from "../aisdk.js"
import { Command } from "../command.js"
@@ -527,39 +526,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
list: (input) =>
Effect.gen(function* () {
const query =
input?.cursor === undefined
? (input ?? {})
: yield* SessionList.Cursor.parse(input.cursor).pipe(Effect.mapError(() => new Error("Invalid cursor")))
const page = yield* sessions.list({ ...query, limit: input?.limit ?? 50 })
const first = page.data[0]
const last = page.data.at(-1)
return {
data: page.data,
cursor: {
previous: first
? SessionList.Cursor.make({
...query,
anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" },
})
: undefined,
next: last
? SessionList.Cursor.make({
...query,
anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" },
})
: undefined,
},
}
}),
active: () =>
sessions.active.pipe(
Effect.map((active) =>
Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])),
),
),
create: (input) =>
sessions.create({
id: input?.id,
+9 -1
View File
@@ -23,7 +23,8 @@ export const Plugin = define({
id: "opencode.vcs.git",
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "git") return
const processes = yield* AppProcess.Service
const adapter = make(processes, {
@@ -35,6 +36,13 @@ export const Plugin = define({
editor.add({
id: "git",
name: "Git",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed"))
}),
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
+14 -1
View File
@@ -26,7 +26,8 @@ export const Plugin = define({
id: "opencode.vcs.hg",
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
// Markerless locations need this definition to initialize their first repository.
if (location.vcs && location.vcs.type !== "hg") return
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
@@ -39,6 +40,18 @@ export const Plugin = define({
editor.add({
id: "hg",
name: "Mercurial",
init: (input) =>
Effect.gen(function* () {
const result = yield* processes.run(
ChildProcess.make("hg", ["init"], {
cwd: input.worktree,
env: { HGPLAIN: "1" },
extendEnv: true,
stdin: "ignore",
}),
)
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Mercurial initialization failed"))
}),
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
+27 -1
View File
@@ -10,6 +10,7 @@ import { VcsEvent } from "@opencode/schema/vcs-event"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
@@ -20,6 +21,10 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
message: Schema.String,
}) {}
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
kind: Schema.Literals(["missing", "conflict", "unknown", "unsupported", "failed"]),
}) {}
export interface DiffOptions {
readonly context?: number
readonly base?: string
@@ -40,6 +45,7 @@ export interface Adapter {
export interface Interface extends Adapter, State.Transformable<VcsEditor> {
readonly base: () => Effect.Effect<Base | null, DiffError>
readonly initialize: (providerID: string) => Effect.Effect<void, InitializeError>
}
interface Data {
@@ -124,7 +130,9 @@ const layer = Layer.effect(
const provider = selected()
const next: Info = provider
? {
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), {
branch: {},
})),
provider: provider.id,
}
: { branch: {} }
@@ -166,6 +174,24 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
initialize: Effect.fn("Vcs.initialize")(function* (providerID: string) {
if (!(yield* fs.isDir(location.project.directory))) return yield* new InitializeError({ kind: "missing" })
if (yield* Project.root(fs, location.project.directory)) return yield* new InitializeError({ kind: "conflict" })
const provider = state.get().providers.get(providerID)
if (!provider) return yield* new InitializeError({ kind: "unknown" })
if (!provider.init) return yield* new InitializeError({ kind: "unsupported" })
yield* provider
.init(scope)
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause).pipe(Effect.orDie)
: Effect.logWarning("vcs initialization failed", { provider: providerID, cause }).pipe(
Effect.andThen(Effect.fail(new InitializeError({ kind: "failed" }))),
),
),
)
}),
info: Effect.fn("Vcs.info")(function* () {
return current.info
}),
+12
View File
@@ -123,6 +123,18 @@ function emitAndWait(update: Watcher.Update) {
}
describe("SkillFile.parse", () => {
test("reads disable-model-invocation as autoinvoke", () => {
const parse = (frontmatter: string) =>
SkillFile.parse("/repo/skills", "/repo/skills/deploy/SKILL.md", `---\n${frontmatter}\n---\n# deploy`)
expect(parse("disable-model-invocation: true")).toMatchObject({ skill: { autoinvoke: false } })
expect(parse("disable-model-invocation: yes")).toMatchObject({ skill: { autoinvoke: false } })
expect(parse("disable-model-invocation: false")).not.toMatchObject({ skill: { autoinvoke: expect.anything() } })
expect(parse("disable-model-invocation: maybe")).not.toMatchObject({ skill: { autoinvoke: expect.anything() } })
expect(
parse("disable-model-invocation: true\nmetadata:\n opencode/autoinvoke: true"),
).toMatchObject({ skill: { autoinvoke: true } })
})
test("parses root and nested skill ids and metadata flags", () => {
const directory = "/repo/skills"
expect(
+114 -4
View File
@@ -20,10 +20,10 @@ import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
]),
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), offlineModels],
),
)
type ConfigInput = typeof Info.Encoded
@@ -59,7 +59,117 @@ function withFormatter<A, E, R>(
)
}
function append(expression: string) {
return [process.execPath, "-e", `require('fs').appendFileSync(process.argv.at(-1), ${expression})`, "$FILE"]
}
const markdown = { command: append("'md'"), extensions: [".md"] }
const project = { command: append("'project'"), extensions: [".project"] }
// Writes formatter config layers from lowest to highest priority: global, project, then project .opencode.
function withLayers<A, E, R>(
layers: ConfigInput["formatter"][],
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
) {
return withTemp((directory) =>
Effect.gen(function* () {
const global = yield* Global.Service
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, ".opencode"))
const files = [
path.join(global.config, "opencode.jsonc"),
path.join(directory, "opencode.json"),
path.join(directory, ".opencode", "opencode.jsonc"),
]
await Promise.all(layers.map((formatter, index) => fs.writeFile(files[index], JSON.stringify({ formatter }))))
})
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
return yield* body(yield* Formatter.Service, directory)
}).pipe(
Effect.scoped,
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
)
}),
)
}
const layered: { name: string; layers: ConfigInput["formatter"][]; expected: Record<string, string | false> }[] = [
{
name: "keeps lower formatters when a higher config adds another",
layers: [{ markdown }, undefined, { project }],
expected: { "test.md": "md", "test.project": "project" },
},
{
name: "inherits omitted fields and replaces supplied arrays",
layers: [
{ markdown: { command: append("'A'"), extensions: [".md", ".project"] } },
{ markdown: { command: append("'B'"), extensions: [".project"] } },
],
expected: { "test.md": false, "test.project": "B" },
},
{
name: "merges environment variables by key",
layers: [
{
markdown: {
...markdown,
command: append("process.env.FIRST + process.env.SECOND"),
environment: { FIRST: "global", SECOND: "global" },
},
},
{ markdown: { environment: { SECOND: "project" } } },
],
expected: { "test.md": "globalproject" },
},
{
name: "keeps a lower disable through overrides that omit it",
layers: [{ markdown: { ...markdown, disabled: true } }, { markdown: { command: append("'override'") }, project }],
expected: { "test.md": false, "test.project": "project" },
},
{
name: "re-enables a lower disabled formatter",
layers: [{ markdown: { ...markdown, disabled: true } }, { markdown: { disabled: false } }],
expected: { "test.md": "md" },
},
{
name: "keeps lower formatters through an empty object",
layers: [{ markdown }, {}],
expected: { "test.md": "md" },
},
{
name: "clears lower formatters when a higher config is false",
layers: [{ markdown }, false],
expected: { "test.md": false },
},
{
name: "clears lower formatters before a later object when a higher config is true",
layers: [{ markdown }, true, { project }],
expected: { "test.md": false, "test.project": "project" },
},
{
name: "enables only formatters configured after false",
layers: [{ markdown }, false, { project }],
expected: { "test.md": false, "test.project": "project" },
},
]
describe("Formatter", () => {
layered.forEach((entry) =>
it.live(entry.name, () =>
withLayers(entry.layers, (formatter, directory) =>
Effect.forEach(Object.entries(entry.expected), ([name, expected]) =>
Effect.gen(function* () {
const file = path.join(directory, name)
yield* Effect.promise(() => fs.writeFile(file, ""))
const formatted = yield* formatter.file(file)
expect(formatted && (yield* Effect.promise(() => fs.readFile(file, "utf8")))).toBe(expected)
}),
),
),
),
)
;[
{ file: "test.match", extension: ".match", matches: true },
{ file: "test.other", extension: ".match", matches: false },
@@ -1,58 +0,0 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { Session } from "@opencode/core/session"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
it.live("Effect plugins list filtered sessions with server-style cursors", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const context = yield* PluginHost.make(plugins)
const first = yield* context.session.create({ title: "match one" })
yield* context.session.create({ title: "different" })
const second = yield* context.session.create({ title: "match two" })
const query = { directory: context.location.directory, limit: 1, order: "asc" as const, search: "match" }
const page = yield* context.session.list(query)
expect(page.data.map((session) => session.id)).toEqual([first.id])
expect(page.cursor.next).toBeDefined()
const next = yield* context.session.list({ cursor: page.cursor.next, limit: 1 })
expect(next.data.map((session) => session.id)).toEqual([second.id])
const previous = yield* context.session.list({ cursor: next.cursor.previous, limit: 1 })
expect(previous.data.map((session) => session.id)).toEqual([first.id])
expect(
yield* context.session.list({ cursor: "invalid" as NonNullable<typeof page.cursor.next> }).pipe(Effect.flip),
).toMatchObject({ message: "Invalid cursor" })
}),
)
it.live("Promise plugins list sessions and report active sessions", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const sessions = yield* Session.Service
const context = yield* PluginHost.make(plugins)
const created = yield* context.session.create({ title: "running" })
const active = yield* PluginHost.make(plugins).pipe(
Effect.provideService(
Session.Service,
Session.Service.of({ ...sessions, active: Effect.succeed(new Set([created.id])) }),
),
)
yield* PluginPromise.fromPromise({
id: "test.session-list-active",
async setup(ctx) {
const page = await ctx.session.list({ directory: ctx.location.directory, search: "running", limit: 1 })
expect(page.data.map((session) => session.id)).toEqual([created.id])
expect(page.cursor.next).toBeDefined()
expect(await ctx.session.list({ cursor: page.cursor.next, limit: 1 })).toMatchObject({ data: [] })
expect(await ctx.session.active()).toEqual({ [created.id]: { type: "running" } })
},
}).effect(active)
}),
)
@@ -0,0 +1,48 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/plugin"
import type { VcsDefinition } from "@opencode/plugin/effect/vcs"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { State } from "@opencode/core/state"
import { Effect } from "effect"
import { it } from "../lib/effect"
import { host } from "./host"
it.live("Promise VCS initializer reaches the registered provider with its location scope", () =>
Effect.gen(function* () {
const calls: string[] = []
const state = State.create({
initial: () => new Map<string, VcsDefinition>(),
editor: (providers) => ({
add: (definition: VcsDefinition) => providers.set(definition.id, definition),
default: { get: () => undefined, set: (_selection: string) => {} },
}),
})
const context = host()
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "init-vcs",
async setup(ctx) {
await ctx.vcs.transform((editor) =>
editor.add({
id: "custom",
name: "Custom VCS",
init: async (scope, { signal }) => {
expect(signal.aborted).toBe(false)
calls.push(scope.worktree)
},
info: async () => ({ branch: {} }),
branches: async () => [],
status: async () => [],
diff: async () => [],
}),
)
},
}),
)
yield* plugin.effect(host({ vcs: { ...context.vcs, transform: state.transform, reload: state.reload } }))
const init = state.get().get("custom")?.init
if (!init) return yield* Effect.die("Initializer was not registered")
yield* init({ directory: "/workspace", worktree: "/workspace", canonical: "/workspace" })
expect(calls).toEqual(["/workspace"])
}),
)
+23
View File
@@ -103,6 +103,29 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
it.live("routes initialization through the requested provider before a repository exists", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const calls: string[] = []
yield* vcs.transform((editor) => {
editor.add(provider({ id: "read-only" }))
editor.add(
provider({
id: "custom",
init: (input) => Effect.sync(() => void calls.push(input.worktree)),
}),
)
})
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unknown" })
expect(yield* vcs.initialize("read-only").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
expect(calls).toEqual([])
yield* vcs.initialize("custom")
expect(calls).toEqual([directory])
}).pipe(provide(directory)),
),
)
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -2,6 +2,7 @@ import type { FileDiffInfo } from "@opencode/client/promise"
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode/util/encode"
import { showToast } from "@opencode/ui/toast"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createComputed, createEffect, createMemo, on, onCleanup } from "solid-js"
@@ -46,6 +47,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
scroll: undefined as HTMLDivElement | undefined,
pendingFile: undefined as string | undefined,
deferRender: false,
initializingGit: false,
// The filter is transient by design: a persisted filter would silently hide
// files after a reload.
filter: "",
@@ -218,6 +220,45 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
if (!stored()) return false
return !diffQuery.isPending
}
const lifetime = { disposed: false }
onCleanup(() => {
lifetime.disposed = true
})
const initializeGit = () => {
if (state.initializingGit) return
const location = view.location
if (!location || !view.server.connected) {
showToast({ variant: "error", title: ctx.t("common.requestFailed") })
return
}
const key = view.key
const sessionID = view.id
setState("initializingGit", true)
void view.server.client.vcs
.init({ location, provider: "git" })
.then(async () => {
if (lifetime.disposed || ctx.signal.aborted || view.key !== key) return
const data = view.server.data
data.project.invalidate()
data.session.invalidate(sessionID)
data.location.invalidate(location)
data.location.vcs.invalidate(location)
await data.project.sync()
await data.session.sync(sessionID)
await Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)])
})
.catch((error: unknown) => {
if (lifetime.disposed || ctx.signal.aborted || view.key !== key) return
showToast({
variant: "error",
title: ctx.t("common.requestFailed"),
description: error instanceof Error ? error.message : undefined,
})
})
.finally(() => {
if (!lifetime.disposed && !ctx.signal.aborted && view.key === key) setState("initializingGit", false)
})
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
@@ -445,6 +486,8 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
kinds,
focusFile,
hasChanges,
initializeGit,
initializingGit: () => state.initializingGit,
loadDiff,
mode,
noGit: createMemo(() => !!view.project && !view.project.vcs),
+7 -15
View File
@@ -1,5 +1,6 @@
import { Match, Show, Switch } from "solid-js"
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode/session-ui/v2/session-review-empty-no-git-v2"
import { Select } from "@opencode/ui/select"
import { useExtension } from "../sdk"
import type { ChangeMode, ReviewModel } from "./model"
@@ -12,7 +13,7 @@ export function ReviewTitle(props: { review: ReviewModel }) {
return ctx.t("ui.sessionReview.title.lastTurn")
}
return (
<Show when={props.review.canReview()}>
<Show when={props.review.canReview() && props.review.options().length > 0}>
<Select
options={props.review.options()}
current={props.review.mode()}
@@ -28,7 +29,6 @@ export function ReviewTitle(props: { review: ReviewModel }) {
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
const ctx = useExtension()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return ctx.t("empty.git")
if (props.review.mode() === "branch") return ctx.t("empty.branch")
@@ -39,14 +39,9 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
<Match when={loading()}>
<div class={props.loadingClass}>{ctx.t("loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{ctx.t("git.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{ctx.t("git.description")}
</div>
</div>
<Match when={props.review.noGit()}>
<div class="h-full flex flex-col">
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
</div>
</Match>
<Match when={true}>
@@ -61,16 +56,13 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
const ctx = useExtension()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<div class="px-6 py-4 text-text-weak">{ctx.t("loadingChanges")}</div>
</Match>
<Match when={noGit()}>
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{ctx.t("git.description")}</div>
</div>
<Match when={props.review.noGit()}>
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
</Match>
<Match when={true}>
<SessionReviewEmptyChangesV2 />
-2
View File
@@ -152,8 +152,6 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi<unknown>,
| "list"
| "active"
| "create"
| "get"
| "remove"
+2 -1
View File
@@ -26,6 +26,7 @@ export interface VcsDiffInput extends VcsScope {
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly init?: (input: VcsScope) => Effect.Effect<void, unknown>
readonly info: (input: VcsScope) => Effect.Effect<Vcs.Info, unknown>
readonly base?: (input: VcsScope) => Effect.Effect<Vcs.Base | null, unknown>
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
@@ -33,7 +34,7 @@ export interface VcsDefinition {
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
}
export interface VcsDomain extends VcsApi<unknown> {
export interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
+2 -2
View File
@@ -518,10 +518,12 @@ export function fromPromise(plugin: Plugin) {
host.vcs.transform((editor) => {
callback({
add: (definition) => {
const init = definition.init?.bind(definition)
const base = definition.base?.bind(definition)
editor.add({
id: definition.id,
name: definition.name,
init: init ? (input) => attempt((signal) => init(input, { signal })) : undefined,
info: (input) => attempt((signal) => definition.info(input, { signal })),
base: base ? (input) => attempt((signal) => base(input, { signal })) : undefined,
branches: (input) => attempt((signal) => definition.branches(input, { signal })),
@@ -579,8 +581,6 @@ export function fromPromise(plugin: Plugin) {
register(
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
list: adaptApiMethod(SessionEndpoints["session.list"], host.session.list),
active: adaptApiMethod(SessionEndpoints["session.active"], host.session.active),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
remove: adaptApiMethod(SessionEndpoints["session.remove"], host.session.remove),
-2
View File
@@ -152,8 +152,6 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi,
| "list"
| "active"
| "create"
| "get"
| "remove"
+2 -1
View File
@@ -25,6 +25,7 @@ export interface VcsDiffInput extends VcsScope {
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly init?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<void>
readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Info>
readonly base?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Base | null>
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
@@ -32,7 +33,7 @@ export interface VcsDefinition {
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
}
export interface VcsDomain extends VcsApi {
export interface VcsDomain extends Omit<VcsApi, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Promise<void>
}
+623 -19
View File
@@ -48,6 +48,106 @@
"summary": "Get server info"
}
},
"/api/pair": {
"post": {
"tags": ["server"],
"operationId": "server.pair",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "PairingCode",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingCode"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
"summary": "Create pairing code"
}
},
"/auth/connect/{code}": {
"get": {
"tags": ["server"],
"operationId": "server.connect",
"parameters": [
{
"name": "code",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "PairingSession",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingSession"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
},
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
]
}
}
}
}
},
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
"summary": "Redeem pairing code"
}
},
"/api/location": {
"get": {
"tags": ["location"],
@@ -886,9 +986,19 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -907,6 +1017,17 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -4508,6 +4629,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4591,21 +4727,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7197,6 +7318,125 @@
"summary": "List MCP resources"
}
},
"/api/credential": {
"get": {
"tags": ["credential"],
"operationId": "credential.list",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Credential.Entry"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "List every stored integration credential, including its secret value.",
"summary": "List credentials"
},
"post": {
"tags": ["credential"],
"operationId": "credential.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Credential.Entry"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
"summary": "Create credential",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Credential.CreateInput"
}
}
},
"required": true
}
}
},
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
@@ -11451,6 +11691,124 @@
}
}
},
"/api/vcs/init": {
"post": {
"tags": ["vcs"],
"operationId": "vcs.init",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
},
{
"name": "provider",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"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"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
},
"501": {
"description": "VcsInitNotSupportedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
"summary": "Initialize VCS repository"
}
},
"/api/vcs": {
"get": {
"tags": ["vcs"],
@@ -13237,6 +13595,27 @@
"required": ["command"],
"additionalProperties": false
},
"Config.Model.Capabilities": {
"type": "object",
"properties": {
"tools": {
"type": "boolean"
},
"input": {
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"Config.Model.CostEncoded": {
"type": "object",
"properties": {
@@ -13328,7 +13707,7 @@
"type": "object"
},
"capabilities": {
"$ref": "#/components/schemas/Model.Capabilities"
"$ref": "#/components/schemas/Config.Model.Capabilities"
},
"variants": {
"type": "array",
@@ -13433,8 +13812,27 @@
}
]
},
"headerTimeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
@@ -13631,6 +14029,9 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13645,6 +14046,9 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13660,6 +14064,128 @@
}
]
},
"Connection.Status": {
"type": "object",
"properties": {
"status": {
"anyOf": [
{
"type": "string",
"enum": ["needs_auth"]
}
]
},
"message": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["status", "message"],
"additionalProperties": false
},
"Credential.CreateInput": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
},
"activate": {
"type": "boolean"
}
},
"required": ["integrationID", "value"],
"additionalProperties": false
},
"Credential.Entry": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"active": {
"type": "boolean"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
}
},
"required": ["id", "integrationID", "label", "active", "value"],
"additionalProperties": false
},
"Credential.Key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["key"]
},
"key": {
"type": "string"
},
"metadata": {
"type": "object"
},
"configuration": {
"$ref": "#/components/schemas/Form.Answer_1"
}
},
"required": ["type", "key"],
"additionalProperties": false
},
"Credential.OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["oauth"]
},
"methodID": {
"type": "string"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "integer",
"minimum": 0
},
"metadata": {
"type": "object"
}
},
"required": ["type", "methodID", "refresh", "access", "expires"],
"additionalProperties": false
},
"Credential.Value": {
"anyOf": [
{
"$ref": "#/components/schemas/Credential.OAuth"
},
{
"$ref": "#/components/schemas/Credential.Key"
}
]
},
"FileDiff.Info": {
"type": "object",
"properties": {
@@ -13746,6 +14272,12 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -15709,6 +16241,29 @@
"Money.USDPerMillionTokens": {
"type": "number"
},
"PairingCode": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"expires_in": {
"type": "integer"
}
},
"required": ["code", "expires_in"],
"additionalProperties": false
},
"PairingSession": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": ["token"],
"additionalProperties": false
},
"Permission.Effect": {
"type": "string",
"enum": ["allow", "deny", "ask"]
@@ -16601,8 +17156,27 @@
}
]
},
"headerTimeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
@@ -18234,6 +18808,16 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -18653,6 +19237,9 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -18929,6 +19516,23 @@
"type": "string",
"enum": ["working", "branch", "committed"]
},
"VcsInitNotSupportedErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["VcsInitNotSupportedError"]
},
"providerID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "providerID", "message"],
"additionalProperties": false
},
"WebSearch.Provider": {
"type": "object",
"properties": {
+98 -11
View File
@@ -5,10 +5,16 @@ import { Session } from "@opencode/schema/session"
import { SessionStats } from "@opencode/schema/session-stats"
import { InstructionEntry } from "@opencode/schema/instruction-entry"
import { Project } from "@opencode/schema/project"
import { AbsolutePath, DateTimeUtcFromMillis, NonNegativeInt } from "@opencode/schema/schema"
import { SessionList } from "@opencode/schema/session-list"
import {
AbsolutePath,
DateTimeUtcFromMillis,
NonNegativeInt,
PositiveInt,
RelativePath,
statics,
} from "@opencode/schema/schema"
import { Event } from "@opencode/schema/event"
import { Context, Effect, Schema, SchemaGetter, Struct } from "effect"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
@@ -37,8 +43,75 @@ import { FileDiff } from "@opencode/schema/file-diff"
import { Form } from "@opencode/schema/form"
import { PublicSessionMessage } from "./message.js"
export const SessionsCursor = SessionList.Cursor
export type SessionsCursor = SessionList.Cursor
const ParentIDFilter = Schema.Union([
Session.ID,
Schema.Null.pipe(
Schema.encodeTo(Schema.Literal("null"), {
decode: SchemaGetter.transform(() => null),
encode: SchemaGetter.transform(() => "null" as const),
}),
),
]).annotate({
description: "Filter by parent session. Use null to return only root sessions.",
})
const SessionsQueryFields = {
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
search: Schema.optional(Schema.String),
parentID: ParentIDFilter.pipe(Schema.optional),
}
const SessionsDirectoryQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath,
})
const SessionsProjectQuery = Schema.Struct({
...SessionsQueryFields,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({
...Struct.omit(fields, ["limit"]),
anchor: Session.ListAnchor,
}))
const SessionsCursorInput = Schema.Union([
withCursor(SessionsDirectoryQuery),
withCursor(SessionsProjectQuery),
withCursor(SessionsAllQuery),
])
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
const invalidCursor = "Invalid cursor" as const
export const SessionsCursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
statics((schema) => {
const make = schema.make.bind(schema)
return {
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
}
}),
)
export type SessionsCursor = typeof SessionsCursor.Type
const SessionActive = Schema.Struct({
type: Schema.Literal("running"),
@@ -85,12 +158,24 @@ const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
}),
)
export const SessionsQuery = SessionList.Query
const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
})
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI extends HttpApiMiddleware.AnyId, FormS>(
sessionLocationMiddleware: Context.Key<I, S>,
formLocationMiddleware: Context.Key<FormI, FormS>,
) =>
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsQueryCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const makeSessionGroup = <
I extends HttpApiMiddleware.AnyId,
S,
FormI extends HttpApiMiddleware.AnyId,
FormS,
>(sessionLocationMiddleware: Context.Key<I, S>, formLocationMiddleware: Context.Key<FormI, FormS>) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
@@ -477,7 +562,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
error: [SessionNotFoundError, SessionBusyError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
.annotateMerge(
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
+29 -2
View File
@@ -3,9 +3,20 @@ import { Location } from "@opencode/schema/location"
import { NonNegativeInt, PositiveInt, optional } from "@opencode/schema/schema"
import { Vcs } from "@opencode/schema/vcs"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ServiceUnavailableError } from "../errors.js"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
const InitQuery = Schema.Struct({
...LocationQuery.fields,
provider: Schema.optional(Schema.String),
})
export class VcsInitNotSupportedError extends Schema.TaggedError<VcsInitNotSupportedError>()(
"VcsInitNotSupportedError",
{ providerID: Schema.String, message: Schema.String },
{ httpApiStatus: 501 },
) {}
const BranchesQuery = Schema.Struct({
...LocationQuery.fields,
@@ -21,6 +32,22 @@ const DiffQuery = Schema.Struct({
})
export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.post("vcs.init", "/api/vcs/init", {
query: InitQuery,
success: HttpApiSchema.NoContent,
error: [ConflictError, InvalidRequestError, VcsInitNotSupportedError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "vcs.init",
summary: "Initialize VCS repository",
description:
"Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
-70
View File
@@ -1,70 +0,0 @@
export * as SessionList from "./session-list.js"
import { Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { Project } from "./project.js"
import { Session } from "./session.js"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
const ParentIDFilter = Schema.Union([
Session.ID,
Schema.Null.pipe(
Schema.encodeTo(Schema.Literal("null"), {
decode: SchemaGetter.transform(() => null),
encode: SchemaGetter.transform(() => "null" as const),
}),
),
]).annotate({
description: "Filter by parent session. Use null to return only root sessions.",
})
const QueryFields = {
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
search: Schema.optional(Schema.String),
parentID: ParentIDFilter.pipe(Schema.optional),
}
const DirectoryQuery = Schema.Struct({ ...QueryFields, directory: AbsolutePath })
const ProjectQuery = Schema.Struct({ ...QueryFields, project: Project.ID, subpath: RelativePath.pipe(Schema.optional) })
const AllQuery = Schema.Struct(QueryFields)
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({ ...Struct.omit(fields, ["limit"]), anchor: Session.ListAnchor }))
const CursorInput = Schema.Union([withCursor(DirectoryQuery), withCursor(ProjectQuery), withCursor(AllQuery)])
const CursorJson = Schema.fromJsonString(CursorInput)
const encodeCursor = Schema.encodeSync(CursorJson)
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
const invalidCursor = "Invalid cursor" as const
export const Cursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
statics((schema) => {
const make = schema.make.bind(schema)
return {
make: (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input))),
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
}
}),
)
export type Cursor = typeof Cursor.Type
export const Query = Schema.Struct({
...QueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: Cursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}).pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
+48 -2
View File
@@ -1,13 +1,59 @@
import { Vcs } from "@opencode/core/vcs"
import { ServiceUnavailableError } from "@opencode/protocol/errors"
import { Project } from "@opencode/core/project"
import { Worktree } from "@opencode/core/worktree"
import { Bus } from "@opencode/core/bus"
import { Plugin } from "@opencode/core/plugin"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol/errors"
import { VcsInitNotSupportedError } from "@opencode/protocol/groups/vcs"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
const project = yield* Project.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
return handlers
.handle("vcs.init", (ctx) =>
Effect.gen(function* () {
const location = yield* Location.Service
const directory = location.project.directory
const providerID = ctx.query.provider ?? "git"
yield* Plugin.awaitActivation
const vcs = yield* Vcs.Service
yield* vcs.initialize(providerID).pipe(
Effect.mapError((error) => {
if (error.kind === "missing")
return new InvalidRequestError({ message: "Project directory does not exist", field: "location" })
if (error.kind === "conflict")
return new ConflictError({ message: "Project already has version control", resource: directory })
if (error.kind === "unknown")
return new InvalidRequestError({
message: "Unknown VCS provider",
field: "provider",
})
if (error.kind === "unsupported")
return new VcsInitNotSupportedError({
providerID,
message: "VCS provider does not support initialization",
})
return new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
}),
)
const resolved = yield* project.resolve(directory)
if (!resolved.vcs)
return yield* new ServiceUnavailableError({ service: providerID, message: "VCS initialization failed" })
yield* locations.invalidate(
Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
)
yield* bus.publish(Worktree.Event.Updated, { projectID: resolved.id })
return HttpApiSchema.NoContent.make()
}),
)
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
+1
View File
@@ -96,6 +96,7 @@ const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
base: () => Effect.succeed(null),
initialize: () => Effect.fail(new Vcs.InitializeError({ kind: "unsupported" })),
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
info: () => Effect.succeed({ branch: {} }),
+199 -1
View File
@@ -1,6 +1,6 @@
import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { describe, expect } from "bun:test"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { Effect, Layer, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
@@ -8,6 +8,204 @@ import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { ServerFetch } from "../src/fetch"
it.live(
"initializes Git in a markerless project and refreshes its location",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
const before = yield* Effect.promise(() =>
fetch(new URL(`/api/location${url.search}`, server.base), { headers: server.headers }),
)
expect(before.status).toBe(200)
const original = yield* Effect.promise(() => before.json())
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => $`git -C ${tmp.path} rev-parse --is-inside-work-tree`.text())).toBe("true\n")
url.pathname = "/api/location"
const location = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(location.status).toBe(200)
const refreshed = yield* Effect.promise(() => location.json())
expect(refreshed).toMatchObject({ project: { directory: tmp.path } })
expect(refreshed.project.id).not.toBe(original.project.id)
const projects = yield* Effect.promise(() =>
fetch(new URL("/api/project", server.base), { headers: server.headers }),
)
expect(yield* Effect.promise(() => projects.json())).toContainEqual(
expect.objectContaining({ id: refreshed.project.id, vcs: "git" }),
)
url.pathname = "/api/vcs"
const vcs = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "git")
throw new Error("Git provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(vcs).toMatchObject({ data: { provider: "git" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.searchParams.delete("mode")
url.pathname = "/api/vcs/init"
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(repeated.status).toBe(409)
}),
15_000,
)
it.live(
"does not create a project directory while initializing Git",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-missing-")))
const server = yield* startServer(path.join(tmp.path, "config"))
const directory = path.join(tmp.path, "absent")
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", directory)
const response = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(response.status).not.toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git", "HEAD")).exists())).toBe(false)
}),
15_000,
)
it.live(
"accepts an explicit Git provider and rejects unknown providers without initializing",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-provider-")))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "unknown")
const unsupported = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(unsupported.status).toBe(400)
expect(yield* Effect.promise(() => unsupported.json())).toMatchObject({
_tag: "InvalidRequestError",
field: "provider",
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
url.searchParams.set("provider", "git")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(true)
}),
15_000,
)
it.live("returns 501 when a registered VCS provider has no initializer", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-no-init-")))
const handler = yield* ServerFetch.make(
{
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SdkPlugins.node.replace(
Layer.succeed(
SdkPlugins.Service,
SdkPlugins.Service.of({
register: () => Effect.void,
all: () => [
{
id: "read-only-vcs",
revision: "test",
effect: (ctx) =>
ctx.vcs
.transform((editor) => {
editor.add({
id: "read-only",
name: "Read-only VCS",
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
})
.pipe(Effect.asVoid),
},
],
}),
),
),
],
},
)
const url = new URL("http://opencode.local/api/vcs/init")
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "read-only")
const response = yield* Effect.promise(() => handler(new Request(url, { method: "POST" })))
expect(response.status).toBe(501)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "VcsInitNotSupportedError",
providerID: "read-only",
})
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "HEAD")).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(false)
}),
)
const describeHg = Bun.which("hg") ? describe : describe.skip
describeHg("Mercurial initialization", () => {
it.live(
"initializes and serves an untracked-file diff through the Hg provider",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-hg-init-")))
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "hello.txt"), "hello\n"))
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/vcs/init", server.base)
url.searchParams.set("location[directory]", tmp.path)
url.searchParams.set("provider", "hg")
const initialized = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(initialized.status).toBe(204)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".hg", "requires")).exists())).toBe(true)
url.pathname = "/api/vcs"
const info = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: server.headers })
const body: unknown = await response.json()
if (!isRecord(body) || !isRecord(body.data) || body.data.provider !== "hg")
throw new Error("Mercurial provider not ready")
return body
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
expect(info).toMatchObject({ data: { provider: "hg" } })
url.pathname = "/api/vcs/diff"
url.searchParams.set("mode", "working")
const diff = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(diff.status).toBe(200)
expect(yield* Effect.promise(() => diff.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ file: "hello.txt" })]),
})
url.pathname = "/api/vcs/init"
url.searchParams.delete("mode")
url.searchParams.set("provider", "git")
const repeated = yield* Effect.promise(() => fetch(url, { method: "POST", headers: server.headers }))
expect(repeated.status).toBe(409)
}),
15_000,
)
})
it.live(
"serves lazy review bases, committed diffs, and unavailable-base errors",
() =>
+628 -20
View File
@@ -48,6 +48,106 @@
"summary": "Get server info"
}
},
"/api/pair": {
"post": {
"tags": ["server"],
"operationId": "server.pair",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "PairingCode",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingCode"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
"summary": "Create pairing code"
}
},
"/auth/connect/{code}": {
"get": {
"tags": ["server"],
"operationId": "server.connect",
"parameters": [
{
"name": "code",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "PairingSession",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingSession"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
},
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
]
}
}
}
}
},
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
"summary": "Redeem pairing code"
}
},
"/api/location": {
"get": {
"tags": ["location"],
@@ -886,9 +986,19 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -907,6 +1017,17 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -4508,6 +4629,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4591,21 +4727,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7197,6 +7318,125 @@
"summary": "List MCP resources"
}
},
"/api/credential": {
"get": {
"tags": ["credential"],
"operationId": "credential.list",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Credential.Entry"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "List every stored integration credential, including its secret value.",
"summary": "List credentials"
},
"post": {
"tags": ["credential"],
"operationId": "credential.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Credential.Entry"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
"summary": "Create credential",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Credential.CreateInput"
}
}
},
"required": true
}
}
},
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
@@ -11451,6 +11691,124 @@
}
}
},
"/api/vcs/init": {
"post": {
"tags": ["vcs"],
"operationId": "vcs.init",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
},
{
"name": "provider",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"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"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
},
"501": {
"description": "VcsInitNotSupportedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
"summary": "Initialize VCS repository"
}
},
"/api/vcs": {
"get": {
"tags": ["vcs"],
@@ -13237,6 +13595,27 @@
"required": ["command"],
"additionalProperties": false
},
"Config.Model.Capabilities": {
"type": "object",
"properties": {
"tools": {
"type": "boolean"
},
"input": {
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"Config.Model.CostEncoded": {
"type": "object",
"properties": {
@@ -13328,7 +13707,7 @@
"type": "object"
},
"capabilities": {
"$ref": "#/components/schemas/Model.Capabilities"
"$ref": "#/components/schemas/Config.Model.Capabilities"
},
"variants": {
"type": "array",
@@ -13433,8 +13812,27 @@
}
]
},
"headerTimeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
@@ -13631,6 +14029,9 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13645,6 +14046,9 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13660,6 +14064,128 @@
}
]
},
"Connection.Status": {
"type": "object",
"properties": {
"status": {
"anyOf": [
{
"type": "string",
"enum": ["needs_auth"]
}
]
},
"message": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["status", "message"],
"additionalProperties": false
},
"Credential.CreateInput": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
},
"activate": {
"type": "boolean"
}
},
"required": ["integrationID", "value"],
"additionalProperties": false
},
"Credential.Entry": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"active": {
"type": "boolean"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
}
},
"required": ["id", "integrationID", "label", "active", "value"],
"additionalProperties": false
},
"Credential.Key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["key"]
},
"key": {
"type": "string"
},
"metadata": {
"type": "object"
},
"configuration": {
"$ref": "#/components/schemas/Form.Answer_1"
}
},
"required": ["type", "key"],
"additionalProperties": false
},
"Credential.OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["oauth"]
},
"methodID": {
"type": "string"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "integer",
"minimum": 0
},
"metadata": {
"type": "object"
}
},
"required": ["type", "methodID", "refresh", "access", "expires"],
"additionalProperties": false
},
"Credential.Value": {
"anyOf": [
{
"$ref": "#/components/schemas/Credential.OAuth"
},
{
"$ref": "#/components/schemas/Credential.Key"
}
]
},
"FileDiff.Info": {
"type": "object",
"properties": {
@@ -13746,6 +14272,12 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -15709,6 +16241,29 @@
"Money.USDPerMillionTokens": {
"type": "number"
},
"PairingCode": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"expires_in": {
"type": "integer"
}
},
"required": ["code", "expires_in"],
"additionalProperties": false
},
"PairingSession": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": ["token"],
"additionalProperties": false
},
"Permission.Effect": {
"type": "string",
"enum": ["allow", "deny", "ask"]
@@ -16601,8 +17156,27 @@
}
]
},
"headerTimeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
@@ -18234,6 +18808,16 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -18653,6 +19237,9 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -18929,6 +19516,23 @@
"type": "string",
"enum": ["working", "branch", "committed"]
},
"VcsInitNotSupportedErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["VcsInitNotSupportedError"]
},
"providerID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "providerID", "message"],
"additionalProperties": false
},
"WebSearch.Provider": {
"type": "object",
"properties": {
@@ -19053,6 +19657,10 @@
"WorktreeErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["WorktreeError"]
},
"name": {
"type": "string",
"enum": ["WorktreeError"]
@@ -19078,7 +19686,7 @@
"additionalProperties": false
}
},
"required": ["name", "data"],
"required": ["_tag", "name", "data"],
"additionalProperties": false
}
},
+628 -20
View File
@@ -48,6 +48,106 @@
"summary": "Get server info"
}
},
"/api/pair": {
"post": {
"tags": ["server"],
"operationId": "server.pair",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "PairingCode",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingCode"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
"summary": "Create pairing code"
}
},
"/auth/connect/{code}": {
"get": {
"tags": ["server"],
"operationId": "server.connect",
"parameters": [
{
"name": "code",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "PairingSession",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingSession"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
},
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
]
}
}
}
}
},
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
"summary": "Redeem pairing code"
}
},
"/api/location": {
"get": {
"tags": ["location"],
@@ -886,9 +986,19 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -907,6 +1017,17 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -4508,6 +4629,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4591,21 +4727,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7197,6 +7318,125 @@
"summary": "List MCP resources"
}
},
"/api/credential": {
"get": {
"tags": ["credential"],
"operationId": "credential.list",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Credential.Entry"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "List every stored integration credential, including its secret value.",
"summary": "List credentials"
},
"post": {
"tags": ["credential"],
"operationId": "credential.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Credential.Entry"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
"summary": "Create credential",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Credential.CreateInput"
}
}
},
"required": true
}
}
},
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
@@ -11451,6 +11691,124 @@
}
}
},
"/api/vcs/init": {
"post": {
"tags": ["vcs"],
"operationId": "vcs.init",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
},
{
"name": "provider",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"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"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
},
"501": {
"description": "VcsInitNotSupportedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
"summary": "Initialize VCS repository"
}
},
"/api/vcs": {
"get": {
"tags": ["vcs"],
@@ -13237,6 +13595,27 @@
"required": ["command"],
"additionalProperties": false
},
"Config.Model.Capabilities": {
"type": "object",
"properties": {
"tools": {
"type": "boolean"
},
"input": {
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"Config.Model.CostEncoded": {
"type": "object",
"properties": {
@@ -13328,7 +13707,7 @@
"type": "object"
},
"capabilities": {
"$ref": "#/components/schemas/Model.Capabilities"
"$ref": "#/components/schemas/Config.Model.Capabilities"
},
"variants": {
"type": "array",
@@ -13433,8 +13812,27 @@
}
]
},
"headerTimeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
@@ -13631,6 +14029,9 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13645,6 +14046,9 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13660,6 +14064,128 @@
}
]
},
"Connection.Status": {
"type": "object",
"properties": {
"status": {
"anyOf": [
{
"type": "string",
"enum": ["needs_auth"]
}
]
},
"message": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["status", "message"],
"additionalProperties": false
},
"Credential.CreateInput": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
},
"activate": {
"type": "boolean"
}
},
"required": ["integrationID", "value"],
"additionalProperties": false
},
"Credential.Entry": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"active": {
"type": "boolean"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
}
},
"required": ["id", "integrationID", "label", "active", "value"],
"additionalProperties": false
},
"Credential.Key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["key"]
},
"key": {
"type": "string"
},
"metadata": {
"type": "object"
},
"configuration": {
"$ref": "#/components/schemas/Form.Answer_1"
}
},
"required": ["type", "key"],
"additionalProperties": false
},
"Credential.OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["oauth"]
},
"methodID": {
"type": "string"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "integer",
"minimum": 0
},
"metadata": {
"type": "object"
}
},
"required": ["type", "methodID", "refresh", "access", "expires"],
"additionalProperties": false
},
"Credential.Value": {
"anyOf": [
{
"$ref": "#/components/schemas/Credential.OAuth"
},
{
"$ref": "#/components/schemas/Credential.Key"
}
]
},
"FileDiff.Info": {
"type": "object",
"properties": {
@@ -13746,6 +14272,12 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -15709,6 +16241,29 @@
"Money.USDPerMillionTokens": {
"type": "number"
},
"PairingCode": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"expires_in": {
"type": "integer"
}
},
"required": ["code", "expires_in"],
"additionalProperties": false
},
"PairingSession": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": ["token"],
"additionalProperties": false
},
"Permission.Effect": {
"type": "string",
"enum": ["allow", "deny", "ask"]
@@ -16601,8 +17156,27 @@
}
]
},
"headerTimeout": {
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"chunkTimeout": {
"type": "number"
"anyOf": [
{
"type": "number"
},
{
"type": "boolean",
"enum": [false]
}
]
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
@@ -18234,6 +18808,16 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -18653,6 +19237,9 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -18929,6 +19516,23 @@
"type": "string",
"enum": ["working", "branch", "committed"]
},
"VcsInitNotSupportedErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["VcsInitNotSupportedError"]
},
"providerID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "providerID", "message"],
"additionalProperties": false
},
"WebSearch.Provider": {
"type": "object",
"properties": {
@@ -19053,6 +19657,10 @@
"WorktreeErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["WorktreeError"]
},
"name": {
"type": "string",
"enum": ["WorktreeError"]
@@ -19078,7 +19686,7 @@
"additionalProperties": false
}
},
"required": ["name", "data"],
"required": ["_tag", "name", "data"],
"additionalProperties": false
}
},
@@ -785,18 +785,6 @@ interface ReferenceDomain extends ReferenceApi<unknown> {
### Sessions
List sessions with server-style filters and cursor pagination. `active()` reports the sessions running in this server
process.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const page = yield* ctx.session.list({ directory: ctx.location.directory, limit: 20, order: "desc" })
const next = page.cursor.next ? yield* ctx.session.list({ cursor: page.cursor.next, limit: 20 }) : undefined
const active = yield* ctx.session.active()
}),
```
Create or read a session, then select the agent and model used by later work.
```ts
@@ -861,8 +849,6 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
```ts
type SessionDomain = Pick<
SessionApi<unknown>,
| "list"
| "active"
| "create"
| "get"
| "remove"
@@ -1100,6 +1086,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Providers can optionally implement `init(scope)` to create a repository in `scope.worktree`. Initialization selects
the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even when
the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
[`FileDiff.Info`](/api#schema-FileDiff.Info).
@@ -1112,10 +1103,15 @@ interface VcsEditor {
}
}
interface VcsDomain extends VcsApi<unknown> {
interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
readonly transform: Transform<VcsEditor>
readonly reload: () => Effect.Effect<void>
}
interface VcsDefinition {
readonly init?: (scope: VcsScope) => Effect.Effect<void, unknown>
// Existing info, branches, status, and diff callbacks are required.
}
```
### Websearch
@@ -742,15 +742,6 @@ await ctx.permission.rules({
### Sessions
List sessions with the same filters and cursor pagination as the server API. The cursor preserves the filters and order;
pass `cursor.next` to load the next page. `active()` returns the sessions currently running in this server process.
```ts
const page = await ctx.session.list({ directory: ctx.location.directory, limit: 20, order: "desc" })
const next = page.cursor.next ? await ctx.session.list({ cursor: page.cursor.next, limit: 20 }) : undefined
const active = await ctx.session.active()
```
Create or read a session.
```ts
@@ -803,8 +794,6 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
```ts
interface SessionContext {
list(input?: SessionListInput, requestOptions?: RequestOptions): Promise<SessionListOutput>
active(requestOptions?: RequestOptions): Promise<SessionActiveOutput>
create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise<SessionInfo>
get(input: SessionGetInput, requestOptions?: RequestOptions): Promise<SessionInfo>
remove(input: SessionRemoveInput, requestOptions?: RequestOptions): Promise<void>
@@ -1056,6 +1045,11 @@ Provider callbacks receive the current location, working-copy root, canonical pr
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Providers can optionally implement `init(scope, { signal })` to create a repository in `scope.worktree`. Initialization
selects the provider by ID (Git by default), not by the detected repository type. Register a provider with `init` even
when the location has no repository yet; OpenCode checks that the directory exists and has no repository before calling
it, then rediscovers the project. New repository formats still need discovery support beyond this callback.
#### Reference
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
@@ -1078,6 +1072,11 @@ interface VcsEditor {
set(providerID: string): void
}
}
interface VcsDefinition {
init?(scope: VcsScope, context: { signal: AbortSignal }): Promise<void>
// Existing info, branches, status, and diff callbacks are required.
}
```
### Worktrees
+4 -3
View File
@@ -26,7 +26,8 @@ available.
```
An object also enables the built-ins and lets you override them or add custom
formatters. An empty object is therefore equivalent to `true`.
formatters. An empty object preserves inherited formatter settings, while `true`
resets them to the built-ins.
```jsonc title="opencode.jsonc"
{
@@ -177,8 +178,8 @@ OpenCode logs the failure and tries the next match.
## Disable
Omit `formatter` or set it to `false` to disable all formatting. An explicit
`false` can override a lower-priority configuration that enabled formatters.
Formatting is disabled when no config file sets `formatter`. To disable
formatting that a lower-priority file enabled, set it to `false`.
```jsonc title="opencode.jsonc"
{
+9 -9
View File
@@ -120,7 +120,6 @@ Use frontmatter to name the skill and control where it appears:
---
name: Git Release
description: Prepare a repository release
slash: true
metadata:
opencode/autoinvoke: false
---
@@ -128,17 +127,16 @@ metadata:
Prepare the changelog, version bump, tag, and release notes.
```
| Field | Behavior |
| ------------------------------ | ------------------------------------------------------------------ |
| `name` | Display name; defaults to the path-derived ID |
| `description` | Summary used to show the skill to the model |
| `slash` | Set to `false` to hide the skill from interactive command catalogs |
| `metadata.opencode/slash` | Boolean or `"true"`/`"false"`; overrides `slash` |
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from the model's available list |
| Field | Behavior |
| ------------------------------ | ----------------------------------------------------------------- |
| `name` | Display name; defaults to the path-derived ID |
| `description` | Summary used to show the skill to the model |
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from the model's available list |
| `disable-model-invocation` | Set to `true` for the same effect as `opencode/autoinvoke: false` |
All frontmatter is optional at runtime. Add a clear `description` when the model should discover the skill; skills without one are not advertised.
`opencode/autoinvoke: false` only hides the skill from the model's available list. The skill remains registered and can still be loaded explicitly by ID. V2 accepts portability fields such as `license` and `compatibility` but does not interpret them.
`opencode/autoinvoke: false` only hides the skill from the model's available list. The skill remains registered and can still be loaded explicitly by ID. When both `disable-model-invocation` and `opencode/autoinvoke` are set, `opencode/autoinvoke` wins. V2 accepts portability fields such as `license` and `compatibility` but does not interpret them.
## IDs
@@ -197,6 +195,8 @@ OpenCode then:
3. Adds the Markdown body, without frontmatter, to the conversation.
4. Provides the skill's base directory and a sample of up to ten supporting file paths.
To load a skill yourself, mention it in your prompt as `@skill-id`.
Supporting file contents are not loaded automatically. The agent reads them when the skill directs it to do so. The file sample is available for directory-based `SKILL.md` skills; flat Markdown skills do not receive a neighboring file list.
## Permissions