Compare commits

..
Author SHA1 Message Date
Kit Langton 3e506cfbde refactor(core): separate configured command invocation 2026-08-28 13:01:59 -04:00
233 changed files with 4489 additions and 9262 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Correct directory page headings when the read offset is zero.
+27 -8
View File
@@ -1000,12 +1000,33 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
// Reasoning is one response-wide channel: it stays open alongside text and
// refusal output so late reasoning deltas and details join the same block,
// and `finishEvents` closes it once with the complete metadata.
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
@@ -1111,12 +1132,10 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
// Snapshot details at publish time so the emitted event never observes later
// mutation of the accumulated `reasoningDetails` array.
const metadata = reasoningMetadata(
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
+8 -49
View File
@@ -7,7 +7,6 @@ import {
AIError,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
LanguageModel,
ToolCallPart,
@@ -1150,7 +1149,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
it.effect("preserves scalar reasoning after content starts", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
@@ -1167,35 +1166,8 @@ describe("OpenAI Chat route", () => {
)
expect(response.reasoning).toBe("detailscalar")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
@@ -1241,18 +1213,7 @@ describe("OpenAI Chat route", () => {
index: 0,
},
]
// Snapshot reasoning-end metadata as each event is published so the
// assertion cannot pass through later mutation of a shared array.
const publishedEndMetadata: unknown[] = []
const response = yield* LLMClient.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (LLMEvent.is.reasoningEnd(event))
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
}),
),
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.map((state) => LLMResponse.complete(state)!),
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
@@ -1273,12 +1234,10 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
// Reasoning stays open alongside text and closes once during finalization.
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
+2 -3
View File
@@ -80,8 +80,8 @@ export function createSessionRequestModel() {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell")
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
return [message.metadata.jobID]
return []
}),
)
@@ -121,7 +121,6 @@ export function createSessionRequestModel() {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
if (typeof shellID === "string" && completed.has(shellID)) return []
const command = part.state.input.command
return [
{
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
+45 -161
View File
@@ -29,7 +29,6 @@ import type {
SessionMessageAssistantTool,
SessionInfo,
SessionInboxInfo,
SessionInboxCompaction,
ShellInfo,
SkillInfo,
VcsInfo,
@@ -285,11 +284,12 @@ export function createData(config: CreateDataInput) {
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
// Inbox IDs of optimistic admissions awaiting acknowledgement, so rejection
// only rolls back unacknowledged rows and a pending re-fetch cannot wipe a
// row the server does not know about yet. Prompts clear on their durable
// echo, positive pending read, or rollback; compactions also reconcile the
// POST's canonical ID.
// Inbox IDs of optimistic prompt admissions still awaiting their durable
// echo. This is the one deliberate piece of in-flight bookkeeping in this
// layer: it exists so a rejection only rolls back rows the server never
// acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
// server does not know about yet. Entries clear on the enqueued echo or on
// rollback — not on POST success, which typically precedes the echo.
const outbox = new Set<string>()
// Session IDs of optimistic create admissions still awaiting acknowledgement
@@ -303,13 +303,11 @@ export function createData(config: CreateDataInput) {
// to exist server-side instead of failing with "not found".
const creating = new Map<string, Promise<unknown>>()
// Per-session send chain: prompts and compactions must be admitted in
// submission order. Each waits for the previous POST to settle, so one
// failure does not block the next.
// Per-session send chain: prompts must be admitted in submission order,
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
// for the previous prompt's POST (settled, so one failure does not block
// the next) before sending its own.
const sending = new Map<string, Promise<unknown>>()
const messageLoads = new Map<string, Promise<unknown>>()
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
onCleanup(() => compacting.clear())
// Register `promise` under `key` until it settles. A later registration
// replaces an earlier one; settlement only clears its own entry.
@@ -321,24 +319,9 @@ export function createData(config: CreateDataInput) {
void promise.then(settle, settle)
}
// Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
function sendAdmission<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
const created = creating.get(sessionID)
const previous = sending.get(sessionID)
const request = Promise.resolve()
.then(() => Promise.all([gate, created, previous]))
.then(send)
track(
sending,
sessionID,
request.catch(() => undefined),
)
return request
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic admission; the upsert is what reconciles
// handler and by optimistic prompt admission; the upsert is what reconciles
// the durable echo with an optimistic placeholder — the durable payload and
// times replace the client's guess.
function admitLocal(item: SessionInboxInfo) {
@@ -351,7 +334,6 @@ export function createData(config: CreateDataInput) {
item.sessionID,
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
)
if (item.type === "compaction") return
const input = store.session.input[item.sessionID] ?? []
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
materializeInboxMessage(item)
@@ -686,7 +668,6 @@ export function createData(config: CreateDataInput) {
draft.push(existing)
message.reindex(draft, index, position)
})
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
return
}
case "session.inbox.delivery.changed":
@@ -694,7 +675,6 @@ export function createData(config: CreateDataInput) {
return
case "session.inbox.cancelled": {
retractLocal(event.data.sessionID, event.data.inboxID)
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
return
}
case "session.inbox.enqueued": {
@@ -705,12 +685,6 @@ export function createData(config: CreateDataInput) {
timeCreated: event.created,
...event.data.item,
})
if (event.data.item.type === "compaction") {
const active = compacting.get(event.data.sessionID)
active?.observed.add(event.data.inboxID)
if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
removePending(event.data.sessionID, active.id)
}
return
}
case "session.instructions.updated":
@@ -1009,7 +983,6 @@ export function createData(config: CreateDataInput) {
time: { created: event.created },
})
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
case "session.execution.succeeded":
case "session.execution.failed":
@@ -1107,7 +1080,6 @@ export function createData(config: CreateDataInput) {
}
message.append(draft, index, failed)
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
case "permission.asked":
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) return
@@ -1294,17 +1266,12 @@ export function createData(config: CreateDataInput) {
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await api().session.inbox.list({ sessionID })
// A positive read acknowledges admission even when its SSE echo is delayed.
pending.forEach((item) => outbox.delete(item.id))
// Compactions also coalesce by Session, not just by the proposed ID.
if (pending.some((item) => item.type === "compaction"))
store.session.pending[sessionID]
?.filter((item) => item.type === "compaction")
.forEach((item) => outbox.delete(item.id))
// Keep optimistic rows still awaiting their echo: this fetch may
// have raced ahead of an in-flight admission the server does not
// know about yet.
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
const inflight = (store.session.pending[sessionID] ?? []).filter(
(item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id),
)
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
batch(() => {
setStore("session", "pending", sessionID, reconcile(merged))
@@ -1378,56 +1345,13 @@ export function createData(config: CreateDataInput) {
if (fresh) track(creating, id, request)
return { id, request }
},
compact(input: { sessionID: string; model?: ModelRef }) {
const active = compacting.get(input.sessionID)
if (active) return active.request
// A known pending control ID may be consumed while setup waits. Propose
// a fresh ID and let the server coalesce, without duplicating its row.
const id = SessionMessage.ID.create()
if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
outbox.add(id)
admitLocal({
id,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "compaction",
delivery: "steer",
payload: {},
})
}
// Compaction admission can coalesce onto a different ID. Retire the
// speculative row on an echo, and remember consumed IDs until the POST
// settles so its older response cannot resurrect a queued row.
const observed = new Set<string>()
const request = sendAdmission(input.sessionID, async () => {
if (input.model) await api().session.switchModel({ sessionID: input.sessionID, model: input.model })
return api().session.compact({ sessionID: input.sessionID, id })
})
.then((item) => {
batch(() => {
outbox.delete(id)
if (item.id !== id) removePending(input.sessionID, id)
if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id)) admitLocal(item)
})
return item
})
.catch((error) => {
if (outbox.delete(id)) removePending(input.sessionID, id)
throw error
})
.finally(() => {
if (compacting.get(input.sessionID)?.request === request) compacting.delete(input.sessionID)
})
compacting.set(input.sessionID, { id, observed, request })
return request
},
// Optimistic prompt admission: render the prompt immediately under a
// client-minted ID, send it, and let the durable inbox.enqueued echo
// upsert that same ID with the server's payload. Server admission is
// idempotent per ID, so retrying with the identical payload cannot
// double-admit.
prompt(input: SessionPromptInput & { gate?: Promise<unknown>; prepare?: () => Promise<unknown> }) {
const { gate, prepare, ...request } = input
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
const { gate, ...request } = input
const id = request.id ?? SessionMessage.ID.create()
// A retry may reuse an ID that is already rendered — and possibly
// already durable. Admit optimistically only for new IDs so a failed
@@ -1453,15 +1377,25 @@ export function createData(config: CreateDataInput) {
},
})
}
return sendAdmission(
// Wrapped so even a synchronous client failure reaches the rollback.
// The POST additionally waits for the caller's gate, for any
// in-flight optimistic create of this session, and for the previous
// prompt's POST: the row renders now, the send happens once the
// session exists server-side and earlier prompts are admitted.
const previous = sending.get(request.sessionID)
const send = Promise.resolve()
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
.then(() => api().session.prompt({ ...request, id }))
track(
sending,
request.sessionID,
async () => {
await prepare?.()
return api().session.prompt({ ...request, id })
},
gate,
).catch((error) => {
// Roll back only rows this call admitted and the server has not
send.then(
() => undefined,
() => undefined,
),
)
return send.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
throw error
@@ -1530,70 +1464,20 @@ export function createData(config: CreateDataInput) {
loading(sessionID: string) {
return store.session.messageLoading[sessionID] ?? false
},
async loadMore(
sessionID: string,
options?: {
all?: boolean
signal?: AbortSignal
/** Runs synchronously inside the store-publication batch. */
beforePublish?: () => void
},
) {
const signal = options?.signal
if (signal?.aborted) return
while (messageLoads.has(sessionID)) {
const published = await (() => {
const pending = messageLoads.get(sessionID)
if (!signal) return pending
const aborted = Promise.withResolvers<void>()
const cancel = () => aborted.resolve()
signal.addEventListener("abort", cancel, { once: true })
return Promise.race([pending, aborted.promise])
.catch((error) => {
if (!signal.aborted) throw error
})
.finally(() => signal.removeEventListener("abort", cancel))
})()
if ((!options?.all && published) || signal?.aborted) return
}
async loadMore(sessionID: string) {
const cursor = store.session.messageCursor[sessionID]
if (!cursor || signal?.aborted) return
if (!cursor || store.session.messageLoading[sessionID]) return
setStore("session", "messageLoading", sessionID, true)
const request = (async () => {
const fetched: SessionMessageInfo[] = []
let next: string | undefined = cursor
do {
const response = await api().message.list(
{
sessionID,
limit: options?.all ? 200 : messagePageLimit,
cursor: next,
},
{ signal },
)
if (signal?.aborted) return
fetched.push(...response.data)
next = response.cursor.next ?? undefined
if (!options?.all) break
} while (next)
// A jump through history publishes once, not once per page of offscreen messages.
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
batch(() => {
options?.beforePublish?.()
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, next)
})
return true
})()
.catch((error) => {
if (!signal?.aborted) throw error
})
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
.finally(() => setStore("session", "messageLoading", sessionID, false))
track(messageLoads, sessionID, request)
await request
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
},
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
@@ -1,400 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise"
test("admits compaction before model setup and serializes the following prompt", async () => {
using fixture = setup()
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const proposed = fixture.data.session.pending.list(sessionID)[0]
expect(proposed).toMatchObject({ type: "compaction", sessionID })
expect(fixture.calls).toEqual([])
expect(fixture.data.session.message.list(sessionID)).toEqual([])
expect(fixture.data.session.status(sessionID)).toBe("idle")
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
await wait(() => fixture.calls.length === 1)
expect(fixture.calls).toEqual(["model"])
fixture.model.resolve()
await wait(() => fixture.calls.length === 2)
expect(fixture.calls).toEqual(["model", "compact"])
fixture.response.resolve(Response.json({ data: item(proposed.id) }))
await Promise.all([compact, prompt])
expect(fixture.calls).toEqual(["model", "compact", "prompt"])
expect(fixture.proposals).toEqual([proposed.id])
})
test("coalesces duplicate gestures until the admission request settles", async () => {
using fixture = setup()
const first = fixture.data.session.compact({ sessionID })
expect(fixture.data.session.compact({ sessionID })).toBe(first)
expect(fixture.data.session.pending.list(sessionID)).toHaveLength(1)
await wait(() => fixture.calls.length === 1)
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await first
expect(fixture.calls).toEqual(["compact"])
const next = fixture.data.session.compact({ sessionID })
expect(next).not.toBe(first)
await next
expect(fixture.calls).toEqual(["compact", "compact"])
expect(new Set(fixture.proposals).size).toBe(2)
expect(fixture.proposals).not.toContain("msg_canonical")
})
test("substitutes the canonical response ID and reconciles its later echo", async () => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID })
const proposed = fixture.data.session.pending.list(sessionID)[0].id
await fixture.data.session.pending.sync(sessionID)
expect(fixture.data.session.pending.list(sessionID).map((row) => row.id)).toEqual([proposed])
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await request
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical")])
fixture.enqueue("msg_canonical", 20)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical", 20)])
expect(fixture.data.session.input.list(sessionID)).toEqual([])
})
test.each(["proposed", "canonical"])("adopts the %s echo before the response without duplicating it", async (kind) => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID })
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
fixture.enqueue(id, 20)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
fixture.response.resolve(Response.json({ data: item(id) }))
await request
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
})
test.each(["started", "cancelled", "failed"])(
"does not resurrect a canonical item already %s before the response",
async (kind) => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID })
fixture.enqueue("msg_canonical")
if (kind === "started")
fixture.emit({
...event,
type: "session.compaction.started",
data: { sessionID, inputID: "msg_canonical", reason: "manual" },
})
if (kind === "cancelled")
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: "msg_canonical" } })
if (kind === "failed")
fixture.emit({
...event,
type: "session.compaction.failed",
data: {
sessionID,
inputID: "msg_canonical",
reason: "manual",
error: { type: "aborted", message: "Cancelled" },
},
})
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await request
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
if (kind === "started") {
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
])
}
},
)
test.each(["model", "compact"])("rolls back a rejected %s RPC and releases the following prompt", async (rpc) => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const failed = request.catch((error: unknown) => error)
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
if (rpc === "model") fixture.model.reject(new Error("Model setup failed"))
if (rpc === "compact") {
fixture.model.resolve()
fixture.response.resolve(new Response("Admission failed", { status: 500 }))
}
expect(await failed).toBeInstanceOf(Error)
await prompt
expect(fixture.data.session.pending.list(sessionID).map((row) => row.type)).toEqual(["user"])
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
})
test.each(["proposed", "canonical", "existing"])(
"preserves acknowledged %s compaction after an HTTP error",
async (kind) => {
using fixture = setup()
if (kind === "existing") fixture.enqueue("msg_canonical")
const request = fixture.data.session.compact({ sessionID })
const failed = request.catch((error: unknown) => error)
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
if (kind !== "existing") fixture.enqueue(id)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
fixture.response.resolve(new Response("Lost response", { status: 500 }))
expect(await failed).toBeInstanceOf(Error)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
expect(fixture.listeners.size).toBe(1)
},
)
test("uses a fresh control ID when the known pending compaction starts during model setup", async () => {
const proposed = Promise.withResolvers<string>()
using fixture = setup(async (request) => {
if (!request.url.endsWith("/compact")) return undefined
const body = await request.json()
proposed.resolve(body.id)
if (body.id === "msg_existing") return Response.json({ message: "Control ID already consumed" }, { status: 409 })
return Response.json({ data: item(body.id) })
})
fixture.enqueue("msg_existing")
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const result = request.catch((error: unknown) => error)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_existing")])
await wait(() => fixture.calls.includes("model"))
fixture.emit({
...event,
type: "session.compaction.started",
data: { sessionID, inputID: "msg_existing", reason: "manual" },
})
fixture.model.resolve()
expect(await proposed.promise).not.toBe("msg_existing")
expect(await result).toEqual(item(await proposed.promise))
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(await proposed.promise)])
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ id: "msg_existing", type: "compaction", status: "running" },
])
})
test.each(["compaction", "canonical compaction", "user"])(
"preserves a fetched durable %s when SSE is delayed and HTTP fails",
async (type) => {
using fixture = setup(async (request) => {
if (request.url.endsWith("/prompt")) return fixture.response.promise
return undefined
})
const request =
type === "user"
? fixture.data.session.prompt({ sessionID, text: "Follow up" })
: fixture.data.session.compact({ sessionID })
const result = request.catch((error: unknown) => error)
const id = type === "canonical compaction" ? "msg_canonical" : fixture.data.session.pending.list(sessionID)[0].id
const durable: SessionInboxInfo =
type === "user" ? { ...item(id, 20), type: "user", payload: { text: "Follow up" } } : item(id, 20)
fixture.pending.push(durable)
await fixture.data.session.pending.sync(sessionID)
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
fixture.response.resolve(new Response("Lost response", { status: 500 }))
expect(await result).toBeInstanceOf(Error)
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
if (type === "user")
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ id, type: "user", text: "Follow up" }])
},
)
test("keeps one event listener and removes it when the data owner is disposed during a gate", async () => {
using fixture = setup()
const gate = Promise.withResolvers<void>()
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
const compact = fixture.data.session.compact({ sessionID })
expect(fixture.listeners.size).toBe(1)
fixture.dispose()
expect(fixture.listeners.size).toBe(0)
gate.resolve()
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await Promise.all([first, compact])
expect(fixture.listeners.size).toBe(0)
})
test("routes concurrent compaction observations by session through one listener", async () => {
const firstResponse = Promise.withResolvers<Response>()
const secondResponse = Promise.withResolvers<Response>()
using fixture = setup(async (request) => {
if (!request.url.endsWith("/compact")) return undefined
return request.url.includes(`/session/${sessionID}/`) ? firstResponse.promise : secondResponse.promise
})
const first = fixture.data.session.compact({ sessionID })
const second = fixture.data.session.compact({ sessionID: "ses_other" })
const firstID = fixture.data.session.pending.list(sessionID)[0].id
const secondID = fixture.data.session.pending.list("ses_other")[0].id
expect(fixture.listeners.size).toBe(1)
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: firstID } })
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
expect(fixture.data.session.pending.list("ses_other").map((row) => row.id)).toEqual([secondID])
firstResponse.resolve(Response.json({ data: item(firstID) }))
secondResponse.resolve(Response.json({ data: { ...item(secondID), sessionID: "ses_other" } }))
await Promise.all([first, second])
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
expect(fixture.data.session.pending.list("ses_other")).toEqual([{ ...item(secondID), sessionID: "ses_other" }])
expect(fixture.listeners.size).toBe(1)
})
test.each(["gate", "prepare"])(
"a preceding prompt's failed %s does not block compaction or following model preparation",
async (kind) => {
using fixture = setup()
const gate = Promise.withResolvers<void>()
const prepared: string[] = []
const first = fixture.data.session
.prompt({
sessionID,
id: "msg_first",
text: "First",
gate: kind === "gate" ? gate.promise : undefined,
prepare: () => {
prepared.push("first")
return gate.promise
},
})
.catch((error: unknown) => error)
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "first" } })
const following = fixture.data.session.prompt({
sessionID,
text: "Follow up",
prepare: () => {
prepared.push("following")
return fixture.api.session.switchModel({ sessionID, model: { providerID: "demo", id: "second" } })
},
})
if (kind === "prepare") await wait(() => prepared.includes("first"))
gate.reject(new Error("Preparation failed"))
expect(await first).toBeInstanceOf(Error)
await wait(() => fixture.calls.includes("model"))
expect(prepared).toEqual(kind === "prepare" ? ["first"] : [])
fixture.model.resolve()
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await Promise.all([compact, following])
expect(fixture.calls).toEqual(["model", "compact", "model", "prompt"])
expect(prepared.at(-1)).toBe("following")
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
},
)
test("creation failure rejects gated prompt, compaction, and following preparation without sending their RPCs", async () => {
const creation = Promise.withResolvers<Response>()
const requested = Promise.withResolvers<void>()
using fixture = setup(async (request) => {
if (!request.url.endsWith("/api/session")) return undefined
requested.resolve()
return creation.promise
})
const gate = Promise.withResolvers<void>()
const prepared: string[] = []
const created = fixture.data.session.create({ id: sessionID })
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const following = fixture.data.session.prompt({
sessionID,
text: "Follow up",
prepare: async () => {
prepared.push("following")
},
})
const results = Promise.allSettled([created.request, first, compact, following])
await requested.promise
creation.resolve(new Response("Creation failed", { status: 500 }))
expect((await results).map((result) => result.status)).toEqual(["rejected", "rejected", "rejected", "rejected"])
expect(fixture.calls).toEqual([])
expect(prepared).toEqual([])
expect(fixture.data.session.get(sessionID)).toBeUndefined()
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
expect(fixture.listeners.size).toBe(1)
gate.resolve()
})
const sessionID = "ses_compact"
const event = { id: "evt_compact", created: 10, durable: { aggregateID: sessionID, seq: 1, version: 1 } }
const item = (id: string, timeCreated = 10): SessionInboxCompaction => ({
id,
sessionID,
timeCreated,
type: "compaction",
delivery: "steer",
payload: {},
})
function setup(override?: (request: Request) => Promise<Response | undefined>) {
const model = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const calls: string[] = []
const proposals: string[] = []
const pending: SessionInboxInfo[] = []
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const overridden = await override?.(request)
if (overridden) return overridden
const rpc = new URL(request.url).pathname.split("/").at(-1)
if (rpc === "inbox") return Response.json({ data: pending })
if (rpc === "model") {
calls.push(rpc)
await model.promise
return new Response(null, { status: 204 })
}
if (rpc === "compact") {
calls.push(rpc)
proposals.push((await request.json()).id)
return (await response.promise).clone()
}
if (rpc === "prompt") {
calls.push(rpc)
return Response.json({
data: { ...item((await request.json()).id), type: "user", payload: { text: "Follow up" } },
})
}
throw new Error(`Unexpected request: ${request.url}`)
},
})
const root = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
return {
data: root.data,
api,
dispose: root.dispose,
[Symbol.dispose]: root.dispose,
model,
response,
calls,
proposals,
pending,
listeners,
emit,
enqueue(id: string, created = 10) {
emit({
...event,
created,
type: "session.inbox.enqueued",
data: { sessionID, inboxID: id, item: { type: "compaction", delivery: "steer", payload: {} } },
})
},
}
}
async function wait(predicate: () => boolean) {
for (let attempt = 0; attempt < 100; attempt++) {
if (predicate()) return
await Bun.sleep(5)
}
throw new Error("Timed out waiting for request")
}
-115
View File
@@ -1,5 +1,4 @@
import { expect, test } from "bun:test"
import { getEventListeners } from "node:events"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
@@ -415,120 +414,6 @@ test("loads bounded message pages", async () => {
}
})
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
"bulk history (%s)",
async (mode) => {
const messages = [1, 2, 3].map((index) => ({
id: `msg_${index}`,
type: "user",
text: `Message ${index}`,
time: { created: index },
}))
const release = Promise.withResolvers<void>()
const controller = new AbortController()
const requests: URL[] = []
const publications: string[][] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input))
requests.push(url)
const cursor = url.searchParams.get("cursor")
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
if (cursor === "recent") {
if (mode.startsWith("join")) await release.promise
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
}
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
await release.promise
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [], cursor: {} })
},
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_refresh")
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
const load = setup.data.session.message.loadMore(
"ses_refresh",
mode.startsWith("join")
? undefined
: {
all: true,
signal: controller.signal,
beforePublish: () => {
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
},
},
)
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
const settled = Promise.allSettled([load, joined])
if (mode.startsWith("join")) {
await wait(() => requests.length === 2)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
controller.abort()
let cancelled = false
void joined.then(() => {
cancelled = true
})
await wait(() => cancelled)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
)
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(requests).toHaveLength(2)
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
)
return
}
await wait(() => requests.length === 4)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
if (mode.startsWith("cancel")) controller.abort()
const retry =
mode === "cancel-retry" || mode === "cancel-page"
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
: undefined
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
)
await retry
const success = mode === "success" || mode === "cancel-retry"
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
)
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
} finally {
release.resolve()
setup.dispose()
}
},
)
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
-2
View File
@@ -1,7 +1,5 @@
/// <reference types="@solidjs/start/env" />
import "@solidjs/start"
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+1 -1
View File
@@ -87,7 +87,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+28 -17
View File
@@ -84,7 +84,10 @@ export function map(input: MapInput): Mapping | undefined {
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(input.settings),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
@@ -293,7 +296,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -308,6 +311,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
@@ -341,21 +345,28 @@ function mapOpenRouter(
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
return mapProviderOptions(settings, [
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"promptCacheKey",
"timeout",
])
const options = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
![
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"promptCacheKey",
"timeout",
].includes(key),
),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
+4 -12
View File
@@ -482,7 +482,8 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
return false
})
return toolResultPart({
@@ -506,7 +507,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return []
}
@@ -515,7 +516,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -534,15 +535,6 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+159 -149
View File
@@ -294,156 +294,162 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const durable = definition.durable
if (!durable) return yield* Effect.void
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string")
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
return committed
}),
)
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
}
@@ -859,12 +865,16 @@ export function configured(options?: Options) {
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
notify: Effect.fn("Catalog.notify")(function* () {
finalize: Effect.fn("Catalog.finalize")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+1 -1
View File
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
+107
View File
@@ -0,0 +1,107 @@
export * as CommandInvocation from "./invocation.js"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/schema/agent"
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { Command } from "../command.js"
import { Location } from "../location.js"
import { ShellSelect } from "../shell/select.js"
// Invocation for configured template commands; source loading and registration stay with the caller.
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
delivery: input.delivery,
})
})
})
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+10 -9
View File
@@ -316,15 +316,16 @@ export const layer = (options?: Options) =>
}
})
const reload = Effect.fn("Config.reload")(
function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
},
(effect) => reloadLock.withPermit(effect),
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
+150
View File
@@ -0,0 +1,150 @@
export * as ConfigFile from "./file.js"
import { isDeepStrictEqual } from "node:util"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Schema, Semaphore } from "effect"
import {
applyEdits,
createScanner,
findNodeAtLocation,
modify,
parseTree,
type Node,
type ParseError,
} from "jsonc-parser"
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
const isJson = Schema.is(Schema.MutableJson)
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
const lock = Semaphore.makeUnsafe(1)
/**
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
* The synchronous callback mutates a source clone; its return value is ignored.
* Validates JSON only; normalization and substitution remain the reader's job.
* Does not discover files, start watchers, or refresh Config state.
* Read-modify-write calls are serialized within this process.
*/
export const update = Effect.fn("ConfigFile.update")(
function* (
filepath: string,
mutate: (draft: Schema.MutableJsonObject) => void,
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
const fs = yield* FSUtil.Service
const text = yield* fs
.readFileString(filepath)
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
const errors: ParseError[] = []
const current = parseSource(text, errors)
if (errors.length || !isDocument(current))
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
const next = yield* Effect.try({
try: () => {
const draft = structuredClone(current)
mutate(draft)
return draft
},
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
if (!isDocument(next))
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
const edits = changes(current, next)
if (!edits.length) return next
const updated = yield* Effect.try({
try: () => edits.reduce(patch, text),
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
})
// Duplicate keys can make parse choose the last value while modify edits the first.
const written = parseSource(updated, errors)
if (errors.length || !isDeepStrictEqual(written, next))
return yield* Effect.fail(
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
)
const temporary = filepath + ".tmp"
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
Effect.andThen(fs.rename(temporary, filepath)),
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
)
return next
},
(effect) => lock.withPermit(effect),
)
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function parseSource(text: string, errors: ParseError[]) {
const root = parseTree(text, errors, { allowTrailingComma: true })
if (!root || errors.length) return undefined
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
// an own JSON key. Construct object entries from the AST without those setters.
const value = (node: Node): unknown => {
if (node.type === "array") return (node.children ?? []).map(value)
if (node.type === "object")
return Object.fromEntries(
(node.children ?? []).map((property) => {
const child = property.children?.[1]
return [property.children?.[0]?.value, child && value(child)]
}),
)
return node.value
}
return value(root)
}
function patch(text: string, edit: Edit) {
if (edit.value !== undefined)
return applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const tree = parseTree(text)
const node = tree && findNodeAtLocation(tree, edit.path)
if (!node) return text
// jsonc-parser removes adjacent comments along with the separator. Remove only
// the property/element itself and one comma, leaving surrounding comments intact.
const target = node.parent?.type === "property" ? node.parent : node
const siblings = target.parent?.children ?? []
const previous = siblings[siblings.indexOf(target) - 1]
const scanner = createScanner(text, true)
scanner.setPosition(target.offset + target.length)
scanner.scan()
const following = text[scanner.getTokenOffset()] === ","
if (!following && previous) {
scanner.setPosition(previous.offset + previous.length)
scanner.scan()
}
return applyEdits(text, [
{ offset: target.offset, length: target.length, content: "" },
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
])
}
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (isDeepStrictEqual(before, after)) return []
if (Array.isArray(before) && Array.isArray(after)) {
return [
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
// Remove from the end so earlier deletions cannot shift later paths.
...before
.slice(after.length)
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
.toReversed(),
]
}
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
}
+8 -3
View File
@@ -292,13 +292,18 @@ function normalizeMcpTimeout(
invalid(path, diagnostics)
return
}
const recognized = Object.entries(ConfigMCP.Timeout.fields).filter(([key]) => own(value, key))
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
if (Object.keys(value).length && !recognized.length) {
invalid(path, diagnostics)
return
}
recognized.forEach(([key, field]) => {
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
recognized.forEach((key) => {
const leaf = decodeEncoded(
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
value[key],
[...path, key],
diagnostics,
)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
+13 -1
View File
@@ -32,7 +32,19 @@ type PathAction =
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
const agentKeys = new Set([
"model",
"variant",
"request",
"system",
"description",
"mode",
"hidden",
"color",
"steps",
"disabled",
"permissions",
])
export const Plugin = define({
id: "opencode.config.agent",
+3 -104
View File
@@ -1,18 +1,12 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { CommandInvocation } from "../../command/invocation.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -29,9 +23,7 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const invoke = yield* CommandInvocation.make(ctx)
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -63,37 +55,7 @@ export const Plugin = define({
draft.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
execute: (input) => invoke(command, input),
})
}
}
@@ -146,66 +108,3 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+20 -19
View File
@@ -83,25 +83,26 @@ export const Plugin = define({
),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(
function* (file?: string) {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
},
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
+3 -1
View File
@@ -20,13 +20,14 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
draft.add(
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
@@ -47,6 +48,7 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
+20 -19
View File
@@ -151,25 +151,26 @@ export const Plugin = define({
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(
function* (file?: string) {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
},
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
@@ -1,35 +0,0 @@
# Effect Drizzle SQLite Adapter
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
builders over Effect's generic `SqlClient`. It is maintained source, not
generated output.
## Provenance
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
SQLite Effect query builders, and shared query-builder utilities. The
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
The exact upstream revision originally copied into this repository is unknown.
The currently pinned `drizzle-orm` version is a compatibility dependency, not
copy provenance.
## Local Boundary
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
facade from `database/database.ts`.
Material local adaptations include:
- a runtime-independent driver over Effect's generic `SqlClient`
- local cache, mapping, and runtime-inspection helpers
- suppressed statement tracing beneath the database operation boundary
- explicit SQLite transactions and savepoints
- native transaction delegation for Durable Object SQLite
- deliberate query-builder variance annotations
Preserve these adaptations when comparing or synchronizing upstream code.
Focused regression coverage is in `test/database-drizzle.test.ts` and
`test/sqlite-workerd.test.ts`.
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
*
* @example
* ```ts
* import { SqliteClient } from "@effect/sql-sqlite-node"
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
* import { Effect } from "effect"
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
*
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
* )
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
getTableColumnsRuntime(this.config.table),
this.config.table._.columns,
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+1 -1
View File
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
+161 -151
View File
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
if (compaction) {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary === true,
candidate.value.summary,
)
if (!pairedSummary) return []
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
.map((part) => part.text)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
@@ -313,14 +313,16 @@ export function transformSession(input: TransformInput): TransformResult {
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => !part.synthetic)
const synthetic = visible.filter((part) => part.synthetic)
const attachments = files.flatMap((part) => migrateFile(part))
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
@@ -328,12 +330,16 @@ export function transformSession(input: TransformInput): TransformResult {
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) => ({
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}))
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
@@ -345,7 +351,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => part.text).join("\n\n"),
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -363,7 +369,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => part.text).join("\n\n"),
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -437,6 +443,7 @@ export function transformSession(input: TransformInput): TransformResult {
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
@@ -481,7 +488,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
})
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
@@ -521,75 +528,76 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const now = Date.now()
yield* db.run(sql`
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -604,79 +612,81 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run(),
)
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
@@ -705,7 +715,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
)
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
+52 -55
View File
@@ -61,23 +61,21 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -91,17 +89,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -109,33 +107,32 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+4 -2
View File
@@ -62,8 +62,9 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Mutation locking is process-local and serializes cooperating OpenCode
* changes; external writes can still race.
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content.
*/
const layer = Layer.effect(
Service,
@@ -128,6 +129,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
/**
* Deferred until the corresponding integrations exist.
*/
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
@@ -25,15 +25,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -52,7 +56,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => state.get().ignore,
current: () => current,
observe,
})
}),
+1 -1
View File
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
find: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
find: (input) =>
Effect.sync(() => {
+3 -36
View File
@@ -1,38 +1,5 @@
# GitHub Copilot AI SDK Adapters
This is a temporary package used primarily for GitHub Copilot compatibility.
This directory contains upstream-derived AI SDK implementations adapted for
GitHub Copilot. It is not a generic OpenAI-compatible provider.
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
## Provenance
- `chat/` is derived from the Vercel AI SDK
`@ai-sdk/openai-compatible` chat implementation.
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
implementation.
- The exact upstream revisions originally copied into this repository are
unknown. Current dependency versions and the `VERSION` constant in
`copilot-provider.ts` are not copy provenance.
## Ownership
Keep `chat/` and `responses/` structurally close to their upstream modules, but
preserve the intentional Copilot adaptations: the `copilot` options and metadata
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
Responses requests with encrypted reasoning, rotating response item IDs, and
explicit function-tool strictness taking precedence over the global fallback.
`copilot-provider.ts` is the local adapter assembly entrypoint used by
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
reconciliation, not vendored SDK code. Authentication, request headers, model
routing, and integration lifecycle are also owned by the provider plugin.
When updating the upstream-shaped modules, compare against both source packages
and reapply the documented Copilot adaptations. Focused regression coverage is
in:
- `test/github-copilot/copilot-chat-model.test.ts`
- `test/github-copilot/convert-to-copilot-messages.test.ts`
- `test/github-copilot/openai-responses-language-model.test.ts`
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
- `test/github-copilot/models.test.ts`
- `test/plugin/provider-github-copilot.test.ts`
Avoid making edits to these files
@@ -653,11 +653,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
if (isActiveText) {
controller.enqueue({
type: "text-end",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
controller.enqueue({ type: "text-end", id: "txt-0" })
}
// go through all tool calls and send the ones that are not finished
+7 -2
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,7 +33,12 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: Readonly<Limits>,
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+107 -101
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -378,111 +378,117 @@ const layer = Layer.effect(
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
yield* Effect.gen(function* () {
const persistence = yield* Effect.sync(() => {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
return attempt.label ?? implementation?.label?.(exit.value)
}).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}, Effect.uninterruptible)
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
})
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
}, Effect.uninterruptible)
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
}),
)
})
const scrub = Effect.fnUntraced(function* () {
const now = yield* Clock.currentTimeMillis
+4 -3
View File
@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
}
@@ -346,7 +346,8 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.backgrounded) yield* Deferred.succeed(result.backgrounded, result.info)
if (result.info && result.backgrounded)
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
return result.info
})
@@ -395,7 +396,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
return result.info
})
-1
View File
@@ -37,7 +37,6 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
+19 -10
View File
@@ -10,14 +10,17 @@ import {
CallToolResultSchema,
ElicitationCompleteNotificationSchema,
ElicitRequestSchema,
GetPromptResultSchema,
type Implementation,
type ElicitRequestFormParams,
type ElicitRequestParams,
type ElicitRequestURLParams,
type ElicitResult,
ListPromptsResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
PromptListChangedNotificationSchema,
PromptSchema,
ResourceListChangedNotificationSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
@@ -26,7 +29,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
@@ -39,6 +41,10 @@ const toError = (error: unknown) => (error instanceof Error ? error : new Error(
const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
prompts: PromptSchema.array(),
})
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {
@@ -157,7 +163,6 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -296,8 +301,12 @@ export const connect = Effect.fnUntraced(function* (
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: catalogTimeout,
})
},
(result) => result.prompts,
),
catch: toError,
@@ -387,7 +396,11 @@ export const connect = Effect.fnUntraced(function* (
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema,
{ signal, timeout: executionTimeout },
),
catch: toError,
}).pipe(
Effect.map((result) => ({
@@ -398,11 +411,7 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
{ name: input.name, arguments: input.args ?? {} },
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+21 -42
View File
@@ -3,24 +3,9 @@ export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import type { Session } from "@opencode-ai/schema/session"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import {
Cause,
Context,
Effect,
Exit,
Fiber,
FiberSet,
Latch,
Layer,
Schema,
Scope,
Semaphore,
Stream,
Types,
} from "effect"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -168,7 +153,6 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -489,11 +473,11 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
entry.status = { status: "failed", error: "Connection closed" }
yield* stopServer(name, entry)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}),
),
)
connection.onLog((message) => fork(serverLog(name, message)))
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
connection.onToolsChanged(() =>
live(
refreshTools(name, entry, connection).pipe(
@@ -528,7 +512,7 @@ export const layer = (options?: Options) =>
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
@@ -559,9 +543,9 @@ export const layer = (options?: Options) =>
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
// after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model.
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
return
}
@@ -573,7 +557,7 @@ export const layer = (options?: Options) =>
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}).pipe(Effect.ensuring(entry.startup.open))
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
@@ -584,9 +568,9 @@ export const layer = (options?: Options) =>
entry.tools = undefined
entry.prompts = undefined
yield* Scope.close(scope, Exit.void)
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
yield* bus.publish(PromptsChanged, { server: name })
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
})
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
@@ -608,7 +592,7 @@ export const layer = (options?: Options) =>
yield* register(name, entry)
if (serverConfig.disabled) {
entry.status = { status: "disabled" }
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
yield* startServer(name, entry)
@@ -624,14 +608,13 @@ export const layer = (options?: Options) =>
yield* disposeServer(name, entry)
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
entries.delete(name)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
})
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -648,7 +631,7 @@ export const layer = (options?: Options) =>
if (entry.config.disabled) {
entry.status = { status: "disabled" }
entry.startup.openUnsafe()
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
continue
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
@@ -690,9 +673,10 @@ export const layer = (options?: Options) =>
bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
),
)
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -717,12 +701,7 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
finalize: reconcile,
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
@@ -759,7 +738,7 @@ export const layer = (options?: Options) =>
const target = yield* requireServer(name)
yield* stopServer(name, target.entry)
target.entry.status = { status: "disabled" }
yield* bus.publish(McpEvent.StatusChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}).pipe(locks.withLock(name))
}),
remove: Effect.fn("MCP.remove")(function* (server) {
@@ -784,7 +763,7 @@ export const layer = (options?: Options) =>
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.callTool({ name: input.name, args: input.args })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+10 -1
View File
@@ -213,11 +213,20 @@ export const authorize = (input: {
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
yield* Effect.tryPromise({
const result = yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
if (result === "AUTHORIZED") {
return {
url: input.config.url,
instructions: `Connected to ${input.name}.`,
mode: "auto" as const,
callback: finalize,
}
}
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
+30 -5
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,9 +26,19 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = PersistentPty.Info
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Attachment = {
readonly info: Info
@@ -151,7 +161,15 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: Parameters<Interface["create"]>[1],
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) {
const response = yield* request(
daemon,
@@ -320,7 +338,14 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: Parameters<Interface["attach"]>[1],
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
const attachment = yield* daemon
+2 -2
View File
@@ -111,7 +111,7 @@ const layer = Layer.effect(
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
@@ -142,7 +142,7 @@ const layer = Layer.effect(
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
+1 -3
View File
@@ -84,9 +84,7 @@ export const Plugin = define({
})
function append(template: string, input: string) {
const value = input.trim()
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
return [template, value].filter(Boolean).join("\n\n")
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
+1 -1
View File
@@ -98,7 +98,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
list: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
return response(agents.list())
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
},
reload: agents.reload,
transform: (callback) =>
+1 -1
View File
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
}
}
})
+1 -1
View File
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -4,6 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Provider } from "../../provider.js"
type MantleSDK = {
languageModel: (modelID: string) => LanguageModelV3
chat: (modelID: string) => LanguageModelV3
responses: (modelID: string) => LanguageModelV3
}
@@ -58,8 +58,10 @@ export const CloudflareAIGatewayPlugin = define({
const config = gatewayConfig(evt.options)
if (!config) return
const metadata = gatewayMetadata(evt.options)
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie)
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
Effect.orDie,
)
const gateway = createAiGateway({
accountId: config.accountId,
gateway: config.gatewayId,
+14 -2
View File
@@ -1,7 +1,8 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { loadSDKFactory } from "./sdk-factory.js"
import { importModule } from "@opencode-ai/util/runtime-import"
export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
@@ -12,7 +13,18 @@ export const DynamicProviderPlugin = define({
Effect.fn(function* (evt) {
if (evt.sdk) return
evt.sdk = ((yield* loadSDKFactory(npm, evt.package)) as (options: any) => any)(evt.options)
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
evt.sdk = mod[match](evt.options)
}),
)
}),
+1 -1
View File
@@ -40,7 +40,7 @@ export const GitLabPlugin = define({
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
const id = evt.model.modelID ?? evt.model.id
if (id.startsWith("duo-workflow-")) {
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider"))
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
const workflowRef =
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
const workflowDefinition =
@@ -1,8 +1,9 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { Provider } from "../../provider.js"
import { loadSDKFactory } from "./sdk-factory.js"
import { importModule } from "@opencode-ai/util/runtime-import"
export const SapAICorePlugin = define({
id: "opencode.provider.sap.ai.core",
@@ -17,7 +18,17 @@ export const SapAICorePlugin = define({
(typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined)
if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey
const factory = yield* loadSDKFactory(npm, evt.package)
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
const factory = mod[match]
if (typeof factory !== "function")
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
@@ -1,18 +0,0 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
const installedPath = packageName.startsWith("file://")
? packageName
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
return mod[match]
})
+1 -1
View File
@@ -50,7 +50,7 @@ export const Plugin = define({
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
app: Context["app"],
) {
const plugins = yield* configuredPlugins()
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
return [
ReportContent,
"",
+4 -6
View File
@@ -76,13 +76,11 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
During ordinary project discovery, OpenCode searches the current Location
directory and every ancestor through the filesystem root, including directories
above the detected project or repository root. It merges direct
`opencode.json(c)` files from the farthest ancestor to the current directory,
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
discovered `.opencode` config overrides every discovered direct config. Global
filesystem configuration has lower precedence than these discovered documents.
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
-16
View File
@@ -78,9 +78,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -92,7 +89,6 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -117,18 +113,6 @@ export const layer = Layer.effect(
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
+5 -4
View File
@@ -137,10 +137,11 @@ const layer = Layer.effect(
strategy: project.vcs.type === "git" ? "git" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact. The row insert commits atomically with the event, so a crash between
// checks retries on the next resolve instead of stranding the announcement.
// The in-flight set keeps concurrent resolves from publishing the same fact
// twice.
// fact (copy.ts registers copy directories directly; those never strand
// sessions and never announce). The row insert commits atomically with the
// event, so a crash between checks retries on the next resolve instead of
// stranding the announcement. The in-flight set keeps concurrent resolves
// from publishing the same fact twice.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
+42 -53
View File
@@ -26,7 +26,6 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -48,71 +47,61 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map(), materialized: new Map() }),
initial: () => ({ sources: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
name,
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
materialized.set(
name,
Info.make({
name,
path: source.path,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
}
},
notify: () =>
Effect.gen(function* () {
for (const info of state.get().materialized.values()) {
const source = info.source
if (source.type !== "git") continue
yield* cache
.ensure({
reference: Repository.parseRemote(source.repository),
branch: source.branch,
refresh: true,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name: info.name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -122,7 +111,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(state.get().materialized.values())
return Array.from(materialized.values())
}),
})
}),
+9 -5
View File
@@ -134,9 +134,10 @@ const layer = Layer.effect(
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return rows.slice(0, input.limit)
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
@@ -146,7 +147,7 @@ const layer = Layer.effect(
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return code === 1 ? [] : rows
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
@@ -177,7 +178,7 @@ const layer = Layer.effect(
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.map((relative) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
@@ -211,7 +212,10 @@ const layer = Layer.effect(
)
},
onItem: input.onEntry,
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
@@ -244,7 +248,7 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.map((match) =>
result.items.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(match.path.text),
+3 -3
View File
@@ -5,8 +5,8 @@ import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "eff
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
import { Workspace } from "./workspace.js"
import { Model } from "./model.js"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable, SessionTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Agent } from "./agent.js"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
+19 -20
View File
@@ -393,27 +393,26 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
@@ -204,6 +204,7 @@ export const layer = (options?: Options) =>
yield* jobs.background(background.id)
yield* jobs.wait({ id: background.id }).pipe(
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
Effect.ignore,
Effect.forkIn(scope),
)
})
+7 -7
View File
@@ -1,13 +1,13 @@
import { DateTime, Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Agent } from "../agent.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { Project } from "../project.js"
import { Provider } from "../provider.js"
import { AbsolutePath, RelativePath } from "../schema.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Workspace } from "../workspace.js"
import { SessionSchema } from "./schema.js"
import type { SessionTable } from "./sql.js"
import { SessionTable } from "./sql.js"
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
import { Money } from "@opencode-ai/schema/money"
@@ -131,7 +131,11 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -155,7 +159,10 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
observation: Observation,
) {
if (!observation.initial && Object.keys(observation.delta).length === 0) return
// The rendered text is frozen into the durable event because re-rendering it
// later would require the original Location-scoped instruction sources.
// The rendered text is frozen into the durable event: replaying it later would
// require the Location-scoped registry that produced it.
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
yield* bus.publish(
SessionEvent.InstructionsUpdated,
+4 -1
View File
@@ -43,7 +43,10 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+23 -17
View File
@@ -4,17 +4,21 @@ import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined>
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"]>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getShell: (shellID: SessionMessage.Shell["shellID"]) => Effect.Effect<SessionMessage.Shell | undefined>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void>
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getShell: (
shellID: SessionMessage.Shell["shellID"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
}
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
@@ -34,14 +38,16 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const created = DateTime.makeUnsafe(event.created)
const latestTool = (assistant: DraftAssistant, id: string) =>
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
)
const latestText = (assistant: DraftAssistant) =>
assistant.content.findLast((item): item is DraftText => item.type === "text")
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant) =>
assistant.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
@@ -48,7 +48,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
@@ -365,11 +364,9 @@ export const layer = Layer.effect(
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
return {
request,
options,
retry,
executeTool,
}
})
+13 -15
View File
@@ -6,13 +6,13 @@ import path from "path"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Agent } from "../agent.js"
import { Model } from "../model.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Workspace } from "../workspace.js"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { InstructionEntry } from "./instruction-entry.js"
@@ -26,10 +26,8 @@ import type { SessionSchema } from "./schema.js"
import { ProjectTable } from "../project/sql.js"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
@@ -55,16 +53,16 @@ const forkTitle = (value?: string) => {
return `${value} (fork #1)`
}
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
return db
.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write}`,
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
@@ -75,7 +73,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
) {
const row = yield* db
.select({
+21 -24
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { InstructionState } from "../instruction-state.js"
@@ -15,7 +15,7 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
@@ -44,7 +44,12 @@ const layer = Layer.effect(
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
@@ -138,7 +143,7 @@ const layer = Layer.effect(
entering && !continuing ? promotable : "steer",
)
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
if (promoted > 0) step = 1
@@ -171,7 +176,7 @@ const layer = Layer.effect(
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* SessionRunnerRetry.make(bus, sessionID)
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
@@ -217,15 +222,6 @@ const layer = Layer.effect(
agent: loaded.agent.id,
model: loaded.model,
prepared,
retry: (cause, error, proposed) =>
retry.decide({
cause,
error,
agent: loaded.agent.id,
model: loaded.model.ref,
hook: prepared.retry,
retry: proposed,
}),
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
@@ -236,17 +232,18 @@ const layer = Layer.effect(
const completed = yield* SessionStep.Outcome.$match(outcome, {
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
Retry: (outcome) =>
retry.wait({
decision: outcome.decision,
error: outcome.error,
assistantMessageID,
}),
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
bus
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
.pipe(Effect.andThen(outcome.cause)),
),
Effect.asVoid,
),
Continue: Effect.fnUntraced(function* (outcome) {
yield* retry.wait({
decision: outcome.decision,
error: outcome.error,
assistantMessageID,
})
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() => outcome.cause),
)
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}),
@@ -1,6 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Clock, Effect, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -46,6 +45,9 @@ export interface StepRecord {
/** Derives canonical model content from a provider-hosted tool result. */
type NonEmptyContent = readonly [Tool.Content, ...Tool.Content[]]
const nonEmpty = (content: ReadonlyArray<Tool.Content>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
@@ -56,7 +58,10 @@ const stringify = (value: unknown) => {
}
const hostedContent = (result: ToolResultValue): NonEmptyContent => {
if (result.type === "content" && isReadonlyArrayNonEmpty(result.value)) return result.value
if (result.type === "content") {
const content = nonEmpty(result.value)
if (content !== undefined) return content
}
return [{ type: "text", text: stringify(result.value) }]
}
@@ -558,12 +563,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
id,
content,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
})
+22 -70
View File
@@ -1,29 +1,17 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Clock, Duration, Effect, Pull, Schedule } from "effect"
import { Duration, Effect, Schedule } from "effect"
import { Bus } from "../../bus.js"
import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
interface Input {
export interface Input {
readonly cause: AIError
readonly error: SessionError.Error
readonly agent: Agent.ID
readonly model: Model.Ref
readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
readonly retry: boolean
}
export interface Decision {
readonly retry: true
readonly attempt: number
readonly delay: number
readonly assistantMessageID: SessionMessage.ID
}
export function isRetryable(error: AIError) {
@@ -67,58 +55,22 @@ const retryAfter = (input: Input) => {
return undefined
}
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
)
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const step = yield* Schedule.toStep(schedule)
let attempt = 1
const decide = (input: Input) =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined)))
if (!next) return { retry: false as const }
const [, duration] = next
attempt++
const delay = Math.ceil(Duration.toMillis(duration))
const event: PluginHooks.Domains["session"]["retry"] = {
sessionID,
agent: input.agent,
model: input.model,
error: input.error,
attempt,
decision: input.retry ? { retry: true, delay } : { retry: false },
}
yield* input.hook(event)
if (!event.decision.retry) return event.decision
const normalized =
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
return { retry: true as const, attempt, delay: normalized }
})
const wait = (input: {
readonly decision: Decision
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
}) =>
Effect.gen(function* () {
const scheduled = yield* Clock.currentTimeMillis
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: input.assistantMessageID,
attempt: input.decision.attempt,
at: scheduled + input.decision.delay,
error: input.error,
})
const remaining = Math.max(0, scheduled + input.decision.delay - (yield* Clock.currentTimeMillis))
yield* Effect.sleep(Duration.millis(remaining))
})
return { decide, wait }
})
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
}),
),
)
+6 -25
View File
@@ -31,11 +31,8 @@ import { SessionRunnerRetry } from "./retry.js"
export type Outcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean }
Retry: { readonly error: SessionError.Error; readonly decision: SessionRunnerRetry.Decision }
Continue: {
readonly error: SessionError.Error
readonly decision: SessionRunnerRetry.Decision
}
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
RecoverFull: {}
Compacted: {}
}>
@@ -47,11 +44,6 @@ interface Input {
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly retry: (
cause: AIError,
error: SessionError.Error,
retry: boolean,
) => Effect.Effect<{ readonly retry: false } | SessionRunnerRetry.Decision>
readonly recoverContinuation: boolean
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
readonly recoverOverflow: Effect.Effect<boolean>
@@ -169,21 +161,10 @@ export const make = Effect.gen(function* () {
!recorded.outputStarted
)
return Outcome.RecoverFull()
const retry =
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
? yield* restore(
input.retry(
llmFailure,
llmError,
SessionRunnerRetry.isRetryable(llmFailure) ||
(recorded.outputStarted && isInterruptedStream(llmFailure)),
),
)
: undefined
if (llmFailure && llmError && retry?.retry && !recorded.outputStarted) {
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
// Retry state projects onto the existing assistant, even before it has produced output.
yield* publisher.startAssistant()
return Outcome.Retry({ error: llmError, decision: retry })
return Outcome.Retry({ cause: llmFailure, error: llmError })
}
if (llmError) yield* publisher.failAssistant(llmError)
@@ -243,12 +224,12 @@ export const make = Effect.gen(function* () {
if (
llmFailure &&
llmError &&
retry?.retry &&
isInterruptedStream(llmFailure) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ error: llmError, decision: retry })
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
+3 -3
View File
@@ -5,10 +5,10 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import type { Project } from "@opencode-ai/schema/project"
import { PermissionV1 } from "../v1/permission.js"
import { Project } from "../project.js"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode-ai/schema/workspace"
import { Workspace } from "../workspace.js"
import { Timestamps } from "../database/schema.sql.js"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
+8 -7
View File
@@ -255,7 +255,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
Effect.tap((rows) =>
Effect.sync(() => {
rows.forEach((row) => {
addToolStatus(toolTotals, row.status)
addToolStatus(toolTotals, row.status, 1)
if (!row.name) return
const tool = tools.get(row.name) ?? {
name: row.name,
@@ -266,7 +266,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
durations: [],
}
tools.set(row.name, tool)
addToolStatus(tool, row.status)
addToolStatus(tool, row.status, 1)
if (row.duration !== null) tool.durations.push(row.duration)
})
}),
@@ -287,7 +287,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
batches(ids.map((row) => row.id)),
(batch) =>
db
.select({ data: EventTable.data })
.select({ created: EventTable.created, data: EventTable.data })
.from(EventTable)
.where(
and(
@@ -385,17 +385,18 @@ function tokenTotal(tokens: Tokens) {
function addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
count: number,
) {
target.calls++
target.calls += count
if (status === "completed") {
target.succeeded++
target.succeeded += count
return
}
if (status === "error") {
target.failed++
target.failed += count
return
}
target.unfinished++
target.unfinished += count
}
function makeDateKey(timezone = "UTC") {
+10 -3
View File
@@ -5,7 +5,6 @@ import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
@@ -310,13 +309,21 @@ function sanitizeToolState(id: string, state: SessionMessage.ToolState): Session
return {
...state,
input: { redacted: `tool-input:${id}` },
content: map(state.content, (item) => sanitizeToolContent(id, item)),
content: [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
],
metadata: meta,
}
return {
...state,
input: { redacted: `tool-input:${id}` },
content: state.content ? map(state.content, (item) => sanitizeToolContent(id, item)) : undefined,
content: state.content
? [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
]
: undefined,
metadata: meta,
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ export * as SessionUsage from "./usage.js"
import type { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { Model } from "@opencode-ai/schema/model"
import type { Model } from "../model.js"
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
+51 -50
View File
@@ -122,8 +122,8 @@ const layer = () =>
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const commands = new Map<Shell.ID, Active>()
const exitOrder: Shell.ID[] = []
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
@@ -132,44 +132,44 @@ const layer = () =>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const command of commands.values()) {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(command.done)
yield* Deferred.interrupt(session.done)
}
commands.clear()
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
if (!command) return yield* new NotFoundError({ id })
return command
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (!command) return
commands.delete(id)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
if (!session) return
sessions.delete(id)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(command.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeCommand(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(commands.values())
.filter((command) => command.info.status === "running")
.map((command) => command.info)
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
@@ -181,24 +181,24 @@ const layer = () =>
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const command = yield* require(id)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const command = yield* require(id)
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, command.size - start)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(command.file, { start, end: start + length - 1 })
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
@@ -212,7 +212,7 @@ const layer = () =>
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: command.size,
size: session.size,
truncated: false,
}
})
@@ -257,7 +257,7 @@ const layer = () =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered command.
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
runFork(
Effect.scoped(
@@ -275,7 +275,7 @@ const layer = () =>
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const command: Active = {
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
@@ -283,7 +283,7 @@ const layer = () =>
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
commands.set(id, command)
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
@@ -291,7 +291,7 @@ const layer = () =>
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
command.size += chunk.length
session.size += chunk.length
}),
),
)
@@ -305,7 +305,7 @@ const layer = () =>
}),
)
yield* outputDone.open
}),
}).pipe(Effect.catch(() => outputDone.open)),
)
yield* Effect.promise(
() =>
@@ -317,8 +317,8 @@ const layer = () =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
@@ -326,10 +326,10 @@ const layer = () =>
yield* beforeWait
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// command still reports success rather than the removal NotFoundError. This runs before
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(command.done, command.info)
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
@@ -339,28 +339,29 @@ const layer = () =>
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeCommand(oldest)
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
command.timeout = (duration) =>
session.timeout = (duration) =>
Effect.gen(function* () {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
command.timeoutFiber = undefined
if (duration === 0 || command.info.status !== "running") return
command.timeoutFiber = runFork(
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* command.timeout(invocation.timeout)
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -370,16 +371,16 @@ const layer = () =>
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, command)
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const command = yield* Deferred.await(ready)
return command.info
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
+4 -2
View File
@@ -7,7 +7,6 @@ import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select.js"
import { lazy } from "../util/lazy.js"
import { Wildcard } from "../util/wildcard.js"
type Part = { type: string; text: string }
@@ -356,7 +355,10 @@ function resolve(asset: string) {
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = lazy(initialize)
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
draft.skills.delete(ID.make(id))
},
}),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+16 -14
View File
@@ -133,34 +133,36 @@ const layer = Layer.effect(
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
source: repo.source,
input: {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
},
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause)))
const ignored = yield* git.index
.ignored({ repository: comparison.source, paths: files })
.pipe(Effect.mapError((cause) => failure("files", cause)))
return files.filter((file) => !ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause)))
const ignored = yield* git.index
.ignored({ repository: comparison.source, paths: files })
.pipe(Effect.mapError((cause) => failure("diff", cause)))
return yield* git.tree
.diff({
...comparison.input,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
})
+95 -89
View File
@@ -1,9 +1,9 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft while deriving state.
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
@@ -16,14 +16,13 @@ export interface Registration {
}
/**
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -34,7 +33,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly notifications: Set<Reload>
readonly reloads: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -42,24 +41,17 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* restore(effect)
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
batch.active = false
const notifications = batch.flush
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
: []
// Accepted writes are not rolled back: one failed observer must not hide
// the other states' changes, or replace the batch body's failure.
yield* Exit.asVoidAll([exit, ...notifications])
return yield* exit
}),
)
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
}
export const inherit = Effect.fnUntraced(function* () {
@@ -73,110 +65,124 @@ export interface Options<State, DraftApi> {
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Synchronously completes derived data after ordered transform replay. */
readonly prepare?: (state: State) => void
/**
* Observes accepted changes outside the read path. Batched writes notify at
* batch completion; reloads debounce notifications. Reads never run this hook.
* Resource reconciliation owns its execution scope and coordination.
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
*/
readonly notify?: () => Effect.Effect<void>
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the latest accepted state, replaying stale inputs synchronously. */
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let closed = false
let pending: Deferred.Deferred<void> | undefined
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
if (options.finalize) yield* options.finalize(options.draft(next))
})
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
// Release scheduling ownership before observers run: an observer may
// request and await another reload without joining this notification.
pending = undefined
yield* notify().pipe(Deferred.into(done))
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
if (debounce) dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (!batch.flush) {
closed = true
return
}
batch.notifications.add(notify)
return
}
if (!debounce) return yield* restore(notify())
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
// No yields between choosing the burst's completion and claiming it.
const done = pending ?? Deferred.makeUnsafe<void>()
if (!pending) {
pending = done
yield* publish(done).pipe(Effect.forkDetach)
}
yield* restore(Deferred.await(done))
}),
)
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
yield* Deferred.await(done)
})
return {
get,
get: () => state,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
yield* materialize()
})
}),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
yield* changed(false)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
}),
reload: () => changed(true),
reload,
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
notify: () =>
finalize: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
+3 -3
View File
@@ -7,7 +7,7 @@
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
- Built-in tool plugins live in `tool/plugin`.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
## Output
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
## Current Gaps
-1
View File
@@ -72,7 +72,6 @@ export const layer = Layer.effect(
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
+2 -2
View File
@@ -6,7 +6,7 @@
*/
export * as EditTool from "./edit.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
@@ -108,7 +108,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
+2 -2
View File
@@ -1,7 +1,7 @@
export * as GlobTool from "./glob.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment/index.js"
@@ -41,7 +41,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
/** Glob leaf that defaults its filesystem root to the active Location. */
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as GrepTool from "./grep.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
@@ -57,7 +57,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
/** Grep leaf that defaults its filesystem root to the active Location. */
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as PatchTool from "./patch.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Result, Schema } from "effect"
@@ -65,7 +65,7 @@ type Prepared =
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as QuestionTool from "./question.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../../form.js"
@@ -47,7 +47,7 @@ export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answer
export const Plugin = {
id: "opencode.tool.question",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
const forms = yield* Form.Service
const permission = yield* Permission.Service
+3 -3
View File
@@ -1,6 +1,6 @@
export * as ReadTool from "./read.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { basename, dirname, join } from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
@@ -29,7 +29,7 @@ const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.
export const Plugin = {
id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
@@ -169,7 +169,7 @@ export const toModelContent = (path: string, offset: number | undefined, output:
] as const
if (output.type === "list-page") {
const start = offset || 1
const start = offset ?? 1
const content = [
output.entries.length === 0
? `Read directory ${path}, 0 entries`
+5 -6
View File
@@ -1,7 +1,7 @@
export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
@@ -102,7 +102,7 @@ const backgroundResult = (shellID: string, file: string) => ({
export const Plugin = {
id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
@@ -280,8 +280,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
// CodeMode children share a tool-call ID, but each shell must own its job.
id: info.id,
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -296,7 +295,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
@@ -305,7 +304,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")
+2 -2
View File
@@ -1,6 +1,6 @@
export * as SkillTool from "./skill.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -31,7 +31,7 @@ const unableToLoad = (name: string, error?: unknown) =>
export const Plugin = {
id: "opencode.tool.skill",
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const skills = yield* Skill.Service
const permission = yield* Permission.Service
+2 -2
View File
@@ -1,7 +1,7 @@
export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
@@ -52,7 +52,7 @@ export const description = [
export const Plugin = {
id: "opencode.tool.subagent",
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -102,7 +102,7 @@ const convert = (content: string, contentType: string, format: Format) => {
export const Plugin = {
id: "opencode.tool.webfetch",
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const permission = yield* Permission.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
@@ -26,7 +26,7 @@ const Output = Schema.Struct({
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const websearch = yield* WebSearch.Service
+2 -2
View File
@@ -6,7 +6,7 @@
*/
export * as WriteTool from "./write.js"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
@@ -45,7 +45,7 @@ export const toModelContent = (output: Output) =>
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
+3 -1
View File
@@ -5,7 +5,9 @@ export abstract class NamedError extends Error {
abstract toObject(): { name: string; data: unknown }
static hasName(error: unknown, name: string): boolean {
return typeof error === "object" && error !== null && "name" in error && error.name === name
return (
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
)
}
static create<Name extends string, Fields extends Schema.Struct.Fields>(

Some files were not shown because too many files have changed in this diff Show More