Compare commits

...

33 Commits

Author SHA1 Message Date
kitlangton 570cb13c19 feat(core): pass session IDs in MCP tool metadata 2026-08-28 18:49:13 +00:00
Kit Langton da57b27277 refactor(core): flatten durable commit validation (#45662) 2026-08-28 14:39:43 -04:00
Kit Langton 42a3fec594 docs(core): clarify runtime ownership (#45671) 2026-08-28 14:39:28 -04:00
Kit Langton d15034264b test(core): reuse session projection fixtures (#45661) 2026-08-28 14:37:33 -04:00
Kit Langton 1ce3c7e580 fix(tui): stop flashing when jumping to session start
Buffer bulk history privately and publish once with a bounded head window. Preserve pending anchor compensation while ensuring newer navigation cancels obsolete Home work. Add atomic-loading and real-App navigation regressions.
2026-08-28 14:35:31 -04:00
Kit Langton 07f27c4eca refactor(core): simplify migration narrowing (#45677) 2026-08-28 14:25:34 -04:00
Kit Langton 196893cfeb refactor(core): remove unused config editor (#45991) 2026-08-28 14:21:21 -04:00
opencode-agent[bot] f6992059be fix(core): isolate shell jobs by shell ID (#45985)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-28 13:13:16 -05:00
Kit Langton 732f949a65 fix(tui): admit compaction requests optimistically (#45973)
Render queued compaction feedback before model setup, coalesce repeated gestures, and reconcile canonical admissions without restoring consumed rows. Serialize prompt and compaction preparation through the existing per-session admission chain.
2026-08-28 14:00:09 -04:00
James Long cd3b12c579 feat(tui): resize terminal panes (#45979) 2026-08-28 13:47:50 -04:00
Kit Langton 0d6232ffef test(core): share git test fixtures (#45640) 2026-08-28 13:37:42 -04:00
Kit Langton 6da20f0efe chore(protocol): refresh committed OpenAPI baseline (#45969) 2026-08-28 13:37:11 -04:00
Aiden Cline 8e25e83e5a fix(core): continue retryable failures after durable output (#45861) 2026-08-28 12:35:00 -05:00
Kit Langton fe188f8722 fix(core): normalize directory page headings (#45641) 2026-08-28 13:29:41 -04:00
Kit Langton e4bc8b765b refactor(core): reuse MCP prompt helpers (#45660) 2026-08-28 13:26:51 -04:00
Kit Langton d28b6e9ac2 test(core): construct valid tool contexts (#45657) 2026-08-28 13:26:41 -04:00
Kit Langton ac3cd1b183 test(core): simplify provider smoke tests (#45656) 2026-08-28 13:26:27 -04:00
Kit Langton c601d3b021 refactor(core): share provider factory loading (#45654) 2026-08-28 13:26:15 -04:00
Kit Langton 9bc2165e5c refactor(core): reuse lazy shell parser initialization (#45651) 2026-08-28 13:26:04 -04:00
Kit Langton 374d317412 test(core): simplify provider test setup (#45635) 2026-08-28 13:25:53 -04:00
Kit Langton e50c89834e refactor(core): reuse plugin response wrapper (#45648) 2026-08-28 13:25:40 -04:00
Kit Langton f367c202d9 refactor(core): remove unused command dependency (#45623) 2026-08-28 13:25:27 -04:00
Kit Langton 9d33d83bb4 refactor(core): reuse native option projection (#45647) 2026-08-28 13:23:33 -04:00
Kit Langton 8f1eff50aa refactor(core): deepen snapshot comparison helper (#45653) 2026-08-28 13:22:33 -04:00
Kit Langton e7918e25fd refactor(core): remove ineffective typed recovery (#45631) 2026-08-28 13:16:13 -04:00
Aiden Cline 1eff84615c fix(ai): keep chat reasoning in one lifecycle (#45864) 2026-08-28 12:14:58 -05:00
Kit Langton f1fd6ba3c0 test(core): await registry readiness barriers (#45630) 2026-08-28 13:14:41 -04:00
Kit Langton 39daec9bec refactor(core): narrow session internals (#45638) 2026-08-28 13:05:16 -04:00
Kit Langton 484f5faf8d docs(plugin): update current API examples (#45627) 2026-08-28 13:04:59 -04:00
Kit Langton 9a227d186f fix(core): remove unreachable MCP authorization path (#45633) 2026-08-28 13:03:17 -04:00
Kit Langton fc27061838 refactor(core): remove tool context aliases (#45639) 2026-08-28 13:03:01 -04:00
Kit Langton d71cc3be77 test(core): guarantee lock fixture cleanup (#45636) 2026-08-28 13:02:50 -04:00
Kit Langton 503e680672 test(core): preserve session layer inference (#45629) 2026-08-28 13:02:21 -04:00
117 changed files with 3766 additions and 2193 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Correct directory page headings when the read offset is zero.
+8 -27
View File
@@ -1000,33 +1000,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
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)
}
// 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?.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)
}
if (delta?.refusal) 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.
@@ -1132,10 +1111,12 @@ 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
+49 -8
View File
@@ -7,6 +7,7 @@ import {
AIError,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
LanguageModel,
ToolCallPart,
@@ -1149,7 +1150,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves scalar reasoning after content starts", () =>
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
@@ -1166,8 +1167,35 @@ describe("OpenAI Chat route", () => {
)
expect(response.reasoning).toBe("detailscalar")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
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.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
@@ -1213,7 +1241,18 @@ describe("OpenAI Chat route", () => {
index: 0,
},
]
const response = yield* LLMClient.generate(request).pipe(
// 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)!),
Effect.provide(
fixedResponse(
sseEvents(
@@ -1234,10 +1273,12 @@ 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(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
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(
response.events.findIndex(LLMEvent.is.textStart),
)
+3 -2
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" && typeof message.metadata.jobID === "string")
return [message.metadata.jobID]
if (message.metadata?.source === "shell")
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
return []
}),
)
@@ -121,6 +121,7 @@ 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 [
{
+161 -45
View File
@@ -29,6 +29,7 @@ import type {
SessionMessageAssistantTool,
SessionInfo,
SessionInboxInfo,
SessionInboxCompaction,
ShellInfo,
SkillInfo,
VcsInfo,
@@ -284,12 +285,11 @@ export function createData(config: CreateDataInput) {
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
// 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.
// 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.
const outbox = new Set<string>()
// Session IDs of optimistic create admissions still awaiting acknowledgement
@@ -303,11 +303,13 @@ 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 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.
// 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.
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.
@@ -319,9 +321,24 @@ 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 prompt admission; the upsert is what reconciles
// handler and by optimistic 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) {
@@ -334,6 +351,7 @@ 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)
@@ -668,6 +686,7 @@ 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":
@@ -675,6 +694,7 @@ 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": {
@@ -685,6 +705,12 @@ 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":
@@ -983,6 +1009,7 @@ 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":
@@ -1080,6 +1107,7 @@ 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
@@ -1266,12 +1294,17 @@ 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) && !pending.some((row) => row.id === item.id),
)
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
batch(() => {
setStore("session", "pending", sessionID, reconcile(merged))
@@ -1345,13 +1378,56 @@ 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> }) {
const { gate, ...request } = input
prompt(input: SessionPromptInput & { gate?: Promise<unknown>; prepare?: () => Promise<unknown> }) {
const { gate, prepare, ...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
@@ -1377,25 +1453,15 @@ export function createData(config: CreateDataInput) {
},
})
}
// 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,
return sendAdmission(
request.sessionID,
send.then(
() => undefined,
() => undefined,
),
)
return send.catch((error) => {
// Roll back only rows this call admitted and the echo has not
async () => {
await prepare?.()
return api().session.prompt({ ...request, id })
},
gate,
).catch((error) => {
// Roll back only rows this call admitted and the server has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
throw error
@@ -1464,20 +1530,70 @@ export function createData(config: CreateDataInput) {
loading(sessionID: string) {
return store.session.messageLoading[sessionID] ?? false
},
async loadMore(sessionID: string) {
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
}
const cursor = store.session.messageCursor[sessionID]
if (!cursor || store.session.messageLoading[sessionID]) return
if (!cursor || signal?.aborted) return
setStore("session", "messageLoading", sessionID, true)
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
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
})
.finally(() => setStore("session", "messageLoading", sessionID, false))
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)
track(messageLoads, sessionID, request)
await request
},
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
@@ -0,0 +1,400 @@
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,4 +1,5 @@
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"
@@ -414,6 +415,120 @@ 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>()
+17 -28
View File
@@ -84,10 +84,7 @@ 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,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
...mapGoogleOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
@@ -296,7 +293,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -311,7 +308,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
...(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 }
@@ -345,28 +341,21 @@ function mapOpenRouter(
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
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 }
return mapProviderOptions(settings, [
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"promptCacheKey",
"timeout",
])
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
+147 -153
View File
@@ -294,162 +294,156 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const durable = definition.durable
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}`,
}),
)
}
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)
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
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.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
}),
)
}
}
}
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
}),
)
})
}
-150
View File
@@ -1,150 +0,0 @@
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 }]
}
@@ -87,7 +87,6 @@ export const Plugin = define({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
@@ -152,7 +151,6 @@ function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
+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.layerWith(sqliteLayer({ storage }))",
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
),
)
+22 -29
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.type === "compaction")
if (compaction?.type === "compaction") {
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
if (compaction) {
const pairedSummary = messages.find(
(candidate) =>
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
candidate.value.summary === true,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
if (!pairedSummary) 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.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
.map((part) => part.text)
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
@@ -313,16 +313,14 @@ export function transformSession(input: TransformInput): TransformResult {
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
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")
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")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
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 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 text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
@@ -330,16 +328,12 @@ export function transformSession(input: TransformInput): TransformResult {
return []
})
.join("\n\n")
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: "" },
)
const agentAttachments = agents.map((part) => ({
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}))
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
@@ -351,7 +345,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
text: synthetic.map((part) => part.text).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -369,7 +363,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
text: synthetic.map((part) => part.text).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -443,7 +437,6 @@ 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) => {
+2 -4
View File
@@ -62,9 +62,8 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* 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.
* Mutation locking is process-local and serializes cooperating OpenCode
* changes; external writes can still race.
*/
const layer = Layer.effect(
Service,
@@ -129,7 +128,6 @@ 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.
+1 -1
View File
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
find: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
return Service.of({
find: (input) =>
Effect.sync(() => {
+3 -4
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).pipe(Effect.ignore)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
}
@@ -346,8 +346,7 @@ 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).pipe(Effect.ignore)
if (result.info && result.backgrounded) yield* Deferred.succeed(result.backgrounded, result.info)
return result.info
})
@@ -396,7 +395,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).pipe(Effect.ignore)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
return result.info
})
+9 -19
View File
@@ -10,17 +10,14 @@ import {
CallToolResultSchema,
ElicitationCompleteNotificationSchema,
ElicitRequestSchema,
GetPromptResultSchema,
type Implementation,
type ElicitRequestFormParams,
type ElicitRequestParams,
type ElicitRequestURLParams,
type ElicitResult,
ListPromptsResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
PromptListChangedNotificationSchema,
PromptSchema,
ResourceListChangedNotificationSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
@@ -41,10 +38,6 @@ 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,
}) {
@@ -163,6 +156,7 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: string
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -301,12 +295,8 @@ export const connect = Effect.fnUntraced(function* (
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: catalogTimeout,
})
},
(cursor) =>
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.prompts,
),
catch: toError,
@@ -396,11 +386,7 @@ export const connect = Effect.fnUntraced(function* (
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema,
{ signal, timeout: executionTimeout },
),
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
catch: toError,
}).pipe(
Effect.map((result) => ({
@@ -411,7 +397,11 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+16 -16
View File
@@ -153,6 +153,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: string
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -473,11 +474,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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}),
),
)
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
connection.onLog((message) => fork(serverLog(name, message)))
connection.onToolsChanged(() =>
live(
refreshTools(name, entry, connection).pipe(
@@ -512,7 +513,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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
@@ -543,9 +544,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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
return
}
@@ -557,7 +558,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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}).pipe(Effect.ensuring(entry.startup.open))
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
@@ -568,9 +569,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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
yield* bus.publish(PromptsChanged, { server: name })
})
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
@@ -592,7 +593,7 @@ export const layer = (options?: Options) =>
yield* register(name, entry)
if (serverConfig.disabled) {
entry.status = { status: "disabled" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
return
}
yield* startServer(name, entry)
@@ -608,7 +609,7 @@ 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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
})
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
@@ -631,7 +632,7 @@ export const layer = (options?: Options) =>
if (entry.config.disabled) {
entry.status = { status: "disabled" }
entry.startup.openUnsafe()
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
continue
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
@@ -673,7 +674,6 @@ 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.create<Data, Draft>({
@@ -738,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 }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}).pipe(locks.withLock(name))
}),
remove: Effect.fn("MCP.remove")(function* (server) {
@@ -763,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 })
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+1 -10
View File
@@ -213,20 +213,11 @@ export const authorize = (input: {
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
const result = yield* Effect.tryPromise({
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`))
+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).pipe(Effect.ignore)
if (previous) yield* Scope.close(previous.scope, Exit.void)
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).pipe(Effect.ignore), {
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
discard: true,
})
inventory = [...nextInventory, ...failures]
+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 agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
return response(agents.list())
},
reload: agents.reload,
transform: (callback) =>
@@ -4,7 +4,6 @@ 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,10 +58,8 @@ 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")).pipe(Effect.orDie)
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
Effect.orDie,
)
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
const gateway = createAiGateway({
accountId: config.accountId,
gateway: config.gatewayId,
+2 -14
View File
@@ -1,8 +1,7 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { loadSDKFactory } from "./sdk-factory.js"
export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
@@ -13,18 +12,7 @@ export const DynamicProviderPlugin = define({
Effect.fn(function* (evt) {
if (evt.sdk) return
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)
evt.sdk = ((yield* loadSDKFactory(npm, evt.package)) as (options: any) => any)(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")).pipe(Effect.orDie)
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider"))
const workflowRef =
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
const workflowDefinition =
@@ -1,9 +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 { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
import { loadSDKFactory } from "./sdk-factory.js"
export const SapAICorePlugin = define({
id: "opencode.provider.sap.ai.core",
@@ -18,17 +17,7 @@ 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 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]
const factory = yield* loadSDKFactory(npm, evt.package)
if (typeof factory !== "function")
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
@@ -0,0 +1,18 @@
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().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
const plugins = yield* configuredPlugins()
return [
ReportContent,
"",
+4 -5
View File
@@ -137,11 +137,10 @@ 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 (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.
// 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.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
+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 "./workspace.js"
import { Model } from "./model.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
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 "./agent.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
@@ -204,7 +204,6 @@ 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 "../agent.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { Project } from "../project.js"
import { Provider } from "../provider.js"
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 { AbsolutePath, RelativePath } from "../schema.js"
import { Workspace } from "../workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { SessionSchema } from "./schema.js"
import { SessionTable } from "./sql.js"
import type { SessionTable } from "./sql.js"
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
import { Money } from "@opencode-ai/schema/money"
@@ -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: replaying it later would
// require the Location-scoped registry that produced it.
// The rendered text is frozen into the durable event because re-rendering it
// later would require the original Location-scoped instruction sources.
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
yield* bus.publish(
SessionEvent.InstructionsUpdated,
+17 -23
View File
@@ -4,21 +4,17 @@ import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
export interface Adapter {
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>
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>
}
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
@@ -38,16 +34,14 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const created = DateTime.makeUnsafe(event.created)
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
)
const latestTool = (assistant: DraftAssistant, id: string) =>
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestText = (assistant: DraftAssistant) =>
assistant.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const latestReasoning = (assistant: DraftAssistant) =>
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* () {
+15 -13
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 "../agent.js"
import { Model } from "../model.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js"
import { Workspace } from "../workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { InstructionEntry } from "./instruction-entry.js"
@@ -26,8 +26,10 @@ import type { SessionSchema } from "./schema.js"
import { ProjectTable } from "../project/sql.js"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
@@ -53,16 +55,16 @@ const forkTitle = (value?: string) => {
return `${value} (fork #1)`
}
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
return db
.update(SessionTable)
.set({
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}`,
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}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
@@ -73,7 +75,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select({
+1 -1
View File
@@ -143,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).pipe(Effect.ignore), {
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
})
if (promoted > 0) step = 1
+6 -1
View File
@@ -221,10 +221,15 @@ export const make = Effect.gen(function* () {
})
}
// After durable output, recovery continues instead of replaying: the
// partial assistant message is already persisted history. Any failure
// the pre-output gate would retry is continued here, plus interrupted
// streams, whose read failures may carry delivery states the retry
// policy rejects for full resends.
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
+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 { PermissionV1 } from "../v1/permission.js"
import { Project } from "../project.js"
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import type { Project } from "@opencode-ai/schema/project"
import type { SessionSchema } from "./schema.js"
import { Workspace } from "../workspace.js"
import type { Workspace } from "@opencode-ai/schema/workspace"
import { Timestamps } from "../database/schema.sql.js"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
+6 -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, 1)
addToolStatus(toolTotals, row.status)
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, 1)
addToolStatus(tool, row.status)
if (row.duration !== null) tool.durations.push(row.duration)
})
}),
@@ -385,18 +385,17 @@ function tokenTotal(tokens: Tokens) {
function addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
count: number,
) {
target.calls += count
target.calls++
if (status === "completed") {
target.succeeded += count
target.succeeded++
return
}
if (status === "error") {
target.failed += count
target.failed++
return
}
target.unfinished += count
target.unfinished++
}
function makeDateKey(timezone = "UTC") {
+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 "../model.js"
import type { Model } from "@opencode-ai/schema/model"
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
+1 -2
View File
@@ -305,7 +305,7 @@ const layer = () =>
}),
)
yield* outputDone.open
}).pipe(Effect.catch(() => outputDone.open)),
}),
)
yield* Effect.promise(
() =>
@@ -356,7 +356,6 @@ const layer = () =>
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
+2 -4
View File
@@ -7,6 +7,7 @@ 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 }
@@ -355,10 +356,7 @@ function resolve(asset: string) {
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
const load = lazy(initialize)
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
+14 -16
View File
@@ -133,36 +133,34 @@ 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 {
source: repo.source,
input: {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
},
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
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))
return comparison.files.filter((file) => !comparison.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 ?? files).filter((file) => !ignored.has(file)),
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
})
+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 applies generic output bounding.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
- 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.
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.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
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.
## Current Gaps
+1
View File
@@ -72,6 +72,7 @@ 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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
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`
+6 -5
View File
@@ -1,7 +1,7 @@
export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
@@ -280,7 +280,8 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
// CodeMode children share a tool-call ID, but each shell must own its job.
id: info.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -295,7 +296,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
@@ -304,7 +305,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, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, job.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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: Context) {
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 as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } 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: PluginContext) {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
+1 -3
View File
@@ -5,9 +5,7 @@ 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 as Record<string, unknown>).name === name
)
return typeof error === "object" && error !== null && "name" in error && error.name === name
}
static create<Name extends string, Fields extends Schema.Struct.Fields>(
-411
View File
@@ -1,411 +0,0 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { parse } from "jsonc-parser"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { ConfigFile } from "@opencode-ai/core/config/file"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { withTempDir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
const it = testEffect(LayerNode.compile(FSUtil.node))
describe("ConfigFile", () => {
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = path.join(tmp.path, "global", "opencode.jsonc")
const target = path.join(tmp.path, "project", "custom.jsonc")
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
yield* fs.writeWithDirs(target, text)
const updated = yield* ConfigFile.update(target, (draft) => {
draft.shell = "updated"
})
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
}),
),
)
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
"shell": "{file:missing-shell.txt}",
"skills": { "paths": ["./skills"] },
"agent": { "review": { "model": "acme/reasoner" } },
"username": "before"
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
expect(draft.shell).toBe("{file:missing-shell.txt}")
expect(draft.skills).toEqual({ paths: ["./skills"] })
draft.username = "after"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
}),
),
)
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"agent": {
"review": { "description": "before", "hidden": true },
// Keep the other definition.
"build": { "description": "unchanged" }
},
"snapshot": true
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
const agent: unknown = draft.agent
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
agent.review.description = "after"
agent.review.color = "blue"
delete agent.review.hidden
delete draft.snapshot
})
expect(updated).toEqual({
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
}),
),
)
it.live("patches array elements without rewriting untouched comments", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"plugins": [
// Keep the first plugin.
"first",
"second",
// Keep the third plugin.
"third",
"fourth"
]
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins[1] = "updated"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
const shortened = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 3)
})
expect(shortened.plugins).toEqual(["first"])
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
const extended = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.push("added", "last")
})
expect(extended.plugins).toEqual(["first", "added", "last"])
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
}),
),
)
it.live("preserves adjacent comments when deleting properties and array elements", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"shell": "remove",
// Keep the model explanation.
"model": "acme/reasoner",
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
"skills": [/* Keep the source explanation. */ "remove",],
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
delete draft.shell
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 1)
draft.skills = []
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
}),
),
)
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(
target,
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
)
const updated = yield* ConfigFile.update(target, (draft) => {
;["__proto__", "constructor", "toString"].forEach((key) => {
delete draft[key]
})
})
expect(updated).toEqual({ shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("preserves and edits object-valued __proto__ source keys", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
const entry: unknown = draft["__proto__"]
if (!isRecord(entry)) throw new Error("Missing fixture entry")
entry.value = "after"
})
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
}),
),
)
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "first", "shell": "second" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "after"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("rereads the selected file for consecutive edits without a watcher", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "shell": "first" }')
yield* ConfigFile.update(target, (draft) => {
draft.shell = "second"
})
yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("second")
draft.username = "added"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("external")
draft.snapshots = false
})
expect(yield* fs.readJson(target)).toEqual(updated)
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
}),
),
)
it.live("serializes concurrent read-modify-write calls", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "count": 0 }')
const increment = ConfigFile.update(target, (draft) => {
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
draft.count++
})
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
}),
),
)
it.live("does not rewrite no-op or structurally equal edits", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{\r\n "plugins": ["first"]\r\n}'
yield* fs.writeFileString(target, text)
const before = yield* fs.stat(target)
yield* ConfigFile.update(target, () => {})
yield* ConfigFile.update(target, (draft) => {
draft.plugins = ["first"]
})
expect(yield* fs.readFileString(target)).toBe(text)
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const cause = new Error("Rejected config update")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
throw cause
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe("Config update failed")
expect(error.cause).toBe(cause)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
yield* ConfigFile.update(target, (draft) => {
draft.shell = "recovered"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
}),
),
)
it.live("ignores callback return values instead of replacing the document", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, "{}")
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
expect(yield* fs.readFileString(target)).toBe("{}")
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
expect(updated).toEqual({ shell: "updated" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("rejects non-JSON mutations before writing", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.invalid = Number.NaN
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
;["", "{", "[]", "null"].forEach((text) => {
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, () => {
throw new Error("Callback must not run")
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Invalid config file: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
})
it.live("reports a missing target without creating it", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "missing.json")
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to read config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.exists(target)).toBe(false)
}),
),
)
it.live("reports write failures without replacing the target", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
yield* fs.makeDirectory(target + ".tmp")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to write config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.readFileString(target)).toBe(text)
}),
),
)
})
+2 -22
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
@@ -12,6 +12,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -28,27 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
+22
View File
@@ -0,0 +1,22 @@
import { Effect } from "effect"
export function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
Object.entries(variables).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
+27
View File
@@ -1,9 +1,12 @@
import { $ } from "bun"
import { execFile } from "child_process"
import fs from "fs/promises"
import path from "path"
import { promisify } from "util"
import { pathToFileURL } from "url"
import { Repository } from "@opencode-ai/core/repository"
import { Effect } from "effect"
import { tmpdir } from "./tmpdir"
const exec = promisify(execFile)
@@ -29,6 +32,30 @@ export async function gitRemote(root: string) {
}
}
export function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
export function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
export async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
export async function commit(source: string, content: string, message: string) {
await fs.writeFile(path.join(source, "README.md"), content)
await git(source, "add", "README.md")
+53
View File
@@ -0,0 +1,53 @@
import { spawn } from "child_process"
import path from "path"
const root = path.join(import.meta.dir, "../..")
export function runLockWorker(entrypoint: string, payload: unknown) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [entrypoint, JSON.stringify(payload)], { cwd: root })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
})
})
}
export function spawnLockWorker(entrypoint: string, payload: unknown) {
return spawn(process.execPath, [entrypoint, JSON.stringify(payload)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
export async function stopLockWorker(proc: ReturnType<typeof spawnLockWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
await closed
return
}
await new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
await closed
}
export async function waitForFile(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await Bun.file(file).exists()) return
await Bun.sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
+30
View File
@@ -0,0 +1,30 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? { prompts: [{ name: "second", description: "Second prompt" }] }
: {
prompts: [
{
name: "first",
description: "First prompt",
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
},
],
nextCursor: "page-2",
},
),
)
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
Promise.resolve({
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
}),
)
await server.connect(new StdioServerTransport())
+18
View File
@@ -0,0 +1,18 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
export function sessionServer() {
const server = new Server({ name: "session", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, () =>
Promise.resolve({
tools: [{ name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } } } }],
}),
)
server.setRequestHandler(CallToolRequestSchema, (request) =>
Promise.resolve({ content: [], structuredContent: request.params }),
)
return server
}
if (import.meta.main) await sessionServer().connect(new StdioServerTransport())
+2 -28
View File
@@ -6,7 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, gitRemote } from "./fixture/git"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -75,30 +75,6 @@ describe("Git", () => {
)
})
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
describe("Git worktrees", () => {
it.live("creates, lists, and removes linked worktrees", () =>
Effect.gen(function* () {
@@ -109,9 +85,7 @@ describe("Git worktrees", () => {
yield* Effect.promise(() => initRepo(root.path))
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })))
const git = yield* Git.Service
const repo = yield* git.repo.discover(directory)
if (!repo) throw new Error("Repository not found")
@@ -1,9 +1,9 @@
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
import { convertToOpenAICompatibleChatMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
import { describe, test, expect } from "bun:test"
describe("system messages", () => {
test("should convert system message content to string", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "system",
content: "You are a helpful assistant with AGENTS.md instructions.",
@@ -21,7 +21,7 @@ describe("system messages", () => {
describe("user messages", () => {
test("should convert messages with only a text part to a string content", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [{ type: "text", text: "Hello" }],
@@ -32,7 +32,7 @@ describe("user messages", () => {
})
test("should convert messages with image parts", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -61,7 +61,7 @@ describe("user messages", () => {
})
test("should convert messages with image parts from Uint8Array", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -90,7 +90,7 @@ describe("user messages", () => {
})
test("should handle URL-based images", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -117,7 +117,7 @@ describe("user messages", () => {
})
test("should handle multiple text parts without flattening", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -141,7 +141,7 @@ describe("user messages", () => {
describe("assistant messages", () => {
test("should convert assistant text messages", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [{ type: "text", text: "Hello back!" }],
@@ -160,7 +160,7 @@ describe("assistant messages", () => {
})
test("should handle assistant message with null content when only tool calls", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -195,7 +195,7 @@ describe("assistant messages", () => {
})
test("should concatenate multiple text parts", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -211,7 +211,7 @@ describe("assistant messages", () => {
describe("tool calls", () => {
test("should stringify arguments to tool calls", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -262,7 +262,7 @@ describe("tool calls", () => {
})
test("should handle text output type in tool results", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "tool",
content: [
@@ -286,7 +286,7 @@ describe("tool calls", () => {
})
test("should handle multiple tool results as separate messages", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "tool",
content: [
@@ -320,7 +320,7 @@ describe("tool calls", () => {
})
test("should handle text plus multiple tool calls", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -373,7 +373,7 @@ describe("tool calls", () => {
describe("reasoning (copilot-specific)", () => {
test("should omit reasoning_text without reasoning_opaque", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -395,7 +395,7 @@ describe("reasoning (copilot-specific)", () => {
})
test("should include reasoning_opaque from providerOptions", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -423,7 +423,7 @@ describe("reasoning (copilot-specific)", () => {
})
test("should include reasoning_opaque from text part providerOptions", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -450,7 +450,7 @@ describe("reasoning (copilot-specific)", () => {
})
test("should handle reasoning-only assistant message", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -479,7 +479,7 @@ describe("reasoning (copilot-specific)", () => {
describe("full conversation", () => {
test("should convert a multi-turn conversation with reasoning", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "system",
content: "You are a helpful assistant.",
+3 -19
View File
@@ -42,7 +42,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { toolDefinitions } from "./lib/tool"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
import { Reference } from "../src/reference"
@@ -701,25 +701,9 @@ describe("LocationServiceMap", () => {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const registry = yield* Tool.Service
// Tool plugins register during the forked PluginSupervisor boot; wait for
// every expected tool rather than relying on batch ordering.
yield* Effect.forEach(
[
"edit",
"glob",
"grep",
"question",
"read",
"shell",
"skill",
"subagent",
"webfetch",
"websearch",
"write",
],
(name) => waitForTool(registry, name),
)
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(registry),
+92 -1
View File
@@ -1,6 +1,7 @@
import { afterAll, describe, expect, test } from "bun:test"
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/core/integration"
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
import { Effect } from "effect"
@@ -27,6 +28,96 @@ const authorize = (redirect_uri?: string) =>
)
describe("MCP OAuth", () => {
test("completes interactive authorization through the loopback callback", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({
access_token: "access",
token_type: "Bearer",
refresh_token: "refresh",
expires_in: 3600,
})
},
})
const credential = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
url: server.url.href,
oauth: { client_id: "client" },
}),
methodID: Integration.MethodID.make("oauth"),
})
const authorizationUrl = new URL(authorization.url)
const redirectValue = authorizationUrl.searchParams.get("redirect_uri")
const state = authorizationUrl.searchParams.get("state")
if (!redirectValue || !state) throw new Error("Missing OAuth redirect parameters")
const redirect = new URL(redirectValue)
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", state)
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
return yield* authorization.callback
}),
),
).finally(() => server.stop(true))
expect(credential.access).toBe("access")
expect(credential.refresh).toBe("refresh")
expect(tokenRequests).toHaveLength(1)
expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code")
expect(tokenRequests[0]?.get("code")).toBe("accepted")
expect(tokenRequests[0]?.get("code_verifier")).not.toBeNull()
})
test("refreshes tokens loaded from a persisted credential", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "next", token_type: "Bearer" })
},
})
const store = McpOAuth.memoryStore()
await store.saveTokens(
McpOAuth.toTokens(
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "expired",
refresh: "refresh",
expires: Date.now() - 1000,
metadata: { serverUrl: server.url.href, tokenType: "Bearer" },
}),
),
)
const oauthProvider = McpOAuth.provider({
redirectUrl: "http://127.0.0.1/callback",
client: { id: "client" },
onRedirect: () => undefined,
store,
})
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
expect(result).toBe("AUTHORIZED")
expect(await store.tokens()).toEqual({ access_token: "next", token_type: "Bearer", refresh_token: "refresh" })
expect(tokenRequests).toHaveLength(1)
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
})
test("shares concurrent refreshes for the same token", async () => {
let requests = 0
const pending = Promise.withResolvers<void>()
+152 -8
View File
@@ -43,8 +43,9 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { sessionServer } from "./fixture/mcp-session"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
@@ -380,6 +381,103 @@ test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
for (const transport of ["stdio", "http"]) {
for (const codemode of [false, true]) {
test(`passes session metadata over ${transport} with codemode=${codemode}`, async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const config = yield* Effect.gen(function* () {
if (transport === "stdio")
return new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-session.ts")],
codemode,
})
const server = yield* Effect.acquireRelease(
Effect.promise(async () => {
const protocol = sessionServer()
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
port: 0,
// This request/response fixture does not need a standalone SSE stream.
fetch: (request) =>
request.method === "GET" ? new Response(null, { status: 405 }) : transport.handleRequest(request),
})
return {
url: http.url.toString(),
close: async () => {
await protocol.close()
await http.stop(true)
},
}
}),
(server) => Effect.promise(server.close),
)
return new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, codemode })
})
yield* Effect.gen(function* () {
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const mcp = yield* Mcp.Service
yield* registration.flush
const snapshot = yield* registry.snapshot()
const catalog = yield* mcp.tools()
expect(catalog[0]?.inputSchema).toEqual({ type: "object", properties: { text: { type: "string" } } })
yield* Effect.forEach(
["ses_mcp_first", "ses_mcp_second"],
(id) =>
Effect.gen(function* () {
const result = yield* snapshot.execute({
sessionID: Session.ID.make(id),
...toolIdentity,
call: {
type: "tool-call",
id: `call_${id}`,
name: codemode ? "execute" : "resources_echo",
input: codemode
? { code: 'return await tools.resources.echo({ text: "hello" })' }
: { text: "hello" },
},
})
const expected = {
name: "echo",
arguments: { text: "hello" },
_meta: { sessionID: id, progressToken: expect.any(Number) },
}
expect(codemode ? JSON.parse(result.output.output) : result.output).toEqual(expected)
}),
{ concurrency: "unbounded" },
)
const result = yield* mcp.callTool({ server: "resources", name: "echo" })
expect(result.structured).toEqual({
name: "echo",
arguments: {},
_meta: { progressToken: expect.any(Number) },
})
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Mcp.node]), [
[Mcp.node, resourceMcpLayer(config)],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Bus.node, events],
[Image.node, imagePassthrough],
]),
),
)
}),
),
)
})
}
}
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
@@ -474,6 +572,37 @@ test("retains output schemas across paginated MCP discovery", async () => {
])
})
test("lists paginated prompts and invokes them through the MCP client", async () => {
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* connect(
"prompts",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-prompts.ts")],
}),
import.meta.dir,
)
return {
prompts: yield* connection.prompts(),
result: yield* connection.prompt({ name: "first", args: { topic: "Effect" } }),
}
}),
),
)
expect(result.prompts).toEqual([
{
name: "first",
description: "First prompt",
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
},
{ name: "second", description: "Second prompt", arguments: undefined },
])
expect(result.result).toEqual({ messages: [{ role: "user", content: { type: "text", text: "Effect" } }] })
})
test("spawns local MCP servers through the location environment", async () => {
const spawns: Array<ChildProcess.Command> = []
const cwd = path.join(import.meta.dir, "fixture")
@@ -1563,7 +1692,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
@@ -1582,7 +1713,11 @@ it.effect("returns content-only MCP results through Code Mode", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
@@ -1605,7 +1740,8 @@ it.effect("returns content-only MCP results through Code Mode", () =>
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_lookup")
const registration = yield* McpTool.Service
yield* registration.flush
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
@@ -1621,7 +1757,8 @@ it.effect("fails the call when MCP reports isError", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_fail")
const registration = yield* McpTool.Service
yield* registration.flush
const execution = yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_is_error"),
@@ -1639,7 +1776,8 @@ it.effect("preserves MCP text and media content for the model", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_media")
const registration = yield* McpTool.Service
yield* registration.flush
const execution = yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_media"),
@@ -1662,7 +1800,10 @@ it.effect("waits for permission before calling an MCP tool", () =>
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const fiber = yield* toolSet
.execute({
@@ -1703,7 +1844,10 @@ it.effect("does not call MCP when permission is blocked", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_blocked"),
+1 -21
View File
@@ -14,6 +14,7 @@ import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { withEnv } from "../fixture/env"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
@@ -29,27 +30,6 @@ const it = testEffect(layer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
Object.entries(variables).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
describe("ModelsDevPlugin", () => {
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
@@ -7,6 +7,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -15,32 +16,10 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareAIGatewayPlugin.effect(host)
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
fx,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = []
const gatewayModelCalls: unknown[] = []
@@ -108,9 +87,8 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
Effect.gen(function* () {
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
).toContainEqual({
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
form: [
@@ -132,7 +110,6 @@ describe("CloudflareAIGatewayPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
@@ -153,7 +130,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -198,7 +174,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -228,7 +203,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -266,7 +240,6 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -298,7 +271,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -321,7 +293,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -345,7 +316,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -375,7 +345,6 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
@@ -384,9 +353,11 @@ describe("CloudflareAIGatewayPlugin", () => {
}),
)
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
).toContainEqual({ type: "key", label: "Gateway API token" })
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
})
const result = yield* aisdk.runSDK({
model: Model.Info.make({
@@ -408,7 +379,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -440,7 +410,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -156,7 +156,6 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
@@ -176,7 +175,6 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.effect("uses the model modelID for the default language model", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const language = yield* aisdk.language(
@@ -21,7 +21,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GithubCopilotPlugin.effect(host)
})
@@ -57,7 +56,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("registers GitHub Copilot device OAuth", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
id: Integration.MethodID.make("device"),
type: "oauth",
label: "Login with GitHub Copilot",
@@ -124,7 +124,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("adds Copilot authentication to native Anthropic requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
@@ -145,7 +146,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies title generation as a background interaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_title"),
agent: Agent.ID.make("title"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
@@ -158,7 +160,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies compaction requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_compaction"),
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
@@ -170,7 +173,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
@@ -221,7 +223,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("selects languageModel when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -240,7 +241,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -259,7 +259,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -320,7 +319,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -350,7 +348,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses the API model ID when selecting responses or chat", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -417,7 +414,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("ignores non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -1,12 +1,12 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -15,32 +15,10 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GitLabPlugin.effect(host)
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
createGitLab: (options: Record<string, unknown>) => {
@@ -64,7 +42,6 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
@@ -102,7 +79,6 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
@@ -128,7 +104,6 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
@@ -170,7 +145,6 @@ describe("GitLabPlugin", () => {
it.effect("ignores non-GitLab SDK packages", () =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
@@ -189,7 +163,6 @@ describe("GitLabPlugin", () => {
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -223,7 +196,6 @@ describe("GitLabPlugin", () => {
it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -251,7 +223,6 @@ describe("GitLabPlugin", () => {
it.effect("uses provider feature flags instead of model settings feature flags", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -278,7 +249,6 @@ describe("GitLabPlugin", () => {
it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
})
describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")
})
it.effect("applies legacy referer headers only to Kilo endpoints", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
@@ -15,14 +15,13 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const integration = yield* Integration.Service
yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
yield* LLMGatewayPlugin.effect(host)
})
describe("LLMGatewayPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")
})
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () {
@@ -9,7 +9,7 @@ import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -38,12 +38,10 @@ function eventually<A>(
}
describe("LMStudioPlugin", () => {
it.effect("is registered as a built-in provider plugin", () =>
Effect.sync(() => {
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
}),
)
test("is registered as a built-in provider plugin", () => {
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
})
it.live("discovers local language models with their capabilities and effective context", () =>
Effect.acquireUseRelease(
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
})
describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")
})
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () {
@@ -28,8 +28,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
yield* OpenAIPlugin.effect(host)
})
const addGithubCopilotPlugin = Effect.fn(function* () {
@@ -65,7 +64,8 @@ describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([
{
id: Integration.MethodID.make("chatgpt-browser"),
type: "oauth",
@@ -3,13 +3,13 @@ import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -18,12 +18,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const bus = yield* Bus.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provideService(Integration.Service, integration),
)
yield* OpencodePlugin.effect(host)
})
function required<T>(value: T | undefined): T {
@@ -45,27 +40,6 @@ function eventually<A>(
})
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
const cost = (input: number, output = 0) => [
{
input: Money.USDPerMillionTokens.make(input),
@@ -81,7 +55,8 @@ describe("OpencodePlugin", () => {
it.effect("registers account and service account methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("opencode")))?.methods).toEqual([
{
id: Integration.MethodID.make("device"),
type: "oauth",
@@ -140,7 +115,8 @@ describe("OpencodePlugin", () => {
expect(requests).toContain("POST /console/auth/device/token")
expect(requests).toContain("GET /console/api/user")
expect(requests).toContain("GET /console/api/orgs")
expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
const credentials = yield* Credential.Service
expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
metadata: { server: `${server.url.origin}/console` },
})
}),
@@ -166,7 +142,8 @@ describe("OpencodePlugin", () => {
(server) =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
const integrations = yield* Integration.Service
const error = yield* integrations.oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
@@ -183,7 +160,8 @@ describe("OpencodePlugin", () => {
it.effect("rejects non-HTTP OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
const integrations = yield* Integration.Service
const error = yield* integrations.oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
@@ -198,7 +176,8 @@ describe("OpencodePlugin", () => {
it.effect("rejects non-string OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
const integrations = yield* Integration.Service
const error = yield* integrations.oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
@@ -299,7 +278,8 @@ describe("OpencodePlugin", () => {
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
headers: { "x-org-id": "org" },
})
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
const integrations = yield* Integration.Service
expect(yield* integrations.get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
expect(model).toMatchObject({
@@ -365,19 +345,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -392,19 +362,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("free")),
modelID: Model.ID.make("free"),
package: Provider.aisdk("test-provider"),
cost: cost(0),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("free"), (draft) => {
draft.cost = cost(0)
})
})
yield* addPlugin()
@@ -421,19 +381,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("output-only")),
modelID: Model.ID.make("output-only"),
package: Provider.aisdk("test-provider"),
cost: cost(0, 1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("output-only"), (draft) => {
draft.cost = cost(0, 1)
})
})
yield* addPlugin()
@@ -450,19 +400,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -484,19 +424,9 @@ describe("OpencodePlugin", () => {
})
})
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -511,23 +441,12 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
settings: { apiKey: "configured" },
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, (draft) => {
draft.package = provider.package
catalog.provider.update(Provider.ID.opencode, (draft) => {
draft.package = Provider.aisdk("test-provider")
draft.settings = { apiKey: "configured" }
})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -542,19 +461,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.openai, () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -19,9 +19,9 @@ const addPlugin = Effect.fn(function* () {
})
describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")),
)
test("is registered so legacy OpenRouter behavior can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")
})
it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () {
@@ -7,6 +7,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -20,32 +21,10 @@ const npm = Npm.Service.of({
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm))
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(vars)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}),
)
}
function model(providerID: string) {
return Model.Info.make({
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make("sap-model")),
@@ -60,7 +39,6 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -83,7 +61,6 @@ describe("SapAICorePlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -102,7 +79,6 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -118,7 +94,6 @@ describe("SapAICorePlugin", () => {
it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
@@ -136,7 +111,6 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -14,7 +14,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* VercelPlugin.effect(host)
})
@@ -54,7 +53,6 @@ describe("VercelPlugin", () => {
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const event = yield* aisdk.runSDK({
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -23,9 +23,9 @@ function required<T>(value: T | undefined): T {
}
describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")
})
it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () {
+2 -2
View File
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { location } from "../fixture/location"
@@ -88,7 +88,7 @@ describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
const id = PtyID.make("pty_missing")
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
+1 -17
View File
@@ -7,8 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { branch, git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { branch, git, read, withRemote } from "./fixture/git"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
@@ -127,21 +126,6 @@ function cacheLayer(root: string) {
])
}
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
function exists(file: string) {
return Effect.promise(() =>
fs.stat(file).then(
+2 -2
View File
@@ -379,7 +379,7 @@ describe("SessionRestart background recovery", () => {
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
])
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
@@ -413,7 +413,7 @@ describe("SessionRestart background recovery", () => {
text: expect.stringContaining("server restarted"),
metadata: {
source: "shell",
jobID: "call-background-shell",
jobID: "sh_background_orphan",
shellID: "sh_background_orphan",
state: "cancelled",
},
@@ -81,7 +81,7 @@ const testLayer = AppNodeBuilder.build(
[Config.node, config],
[Image.node, imageLayer],
],
) as unknown as Layer.Layer<unknown>
)
const it = testEffect(testLayer)
+42 -236
View File
@@ -60,25 +60,32 @@ const assistantRow = (
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
...overrides,
})
.run()
return db
})
describe("SessionProjector", () => {
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const db = yield* seedSession()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
@@ -95,22 +102,7 @@ describe("SessionProjector", () => {
it.effect("loads legacy revert storage into canonical state", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const db = yield* seedSession()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
@@ -131,28 +123,14 @@ describe("SessionProjector", () => {
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
const db = yield* seedSession({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
const boundary = SessionMessage.ID.make("msg_boundary")
const earlier = SessionMessage.ID.make("msg_earlier")
yield* db
@@ -227,24 +205,7 @@ describe("SessionProjector", () => {
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* seedSession()
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
@@ -300,24 +261,7 @@ describe("SessionProjector", () => {
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const messageID = SessionMessage.ID.make("msg_malformed")
yield* db
.insert(SessionMessageTable)
@@ -344,24 +288,7 @@ describe("SessionProjector", () => {
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
@@ -387,26 +314,7 @@ describe("SessionProjector", () => {
it.effect("projects durable context messages supported by the updater", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession({ agent: "plan", model: previousModel })
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.AgentSelected, {
@@ -532,24 +440,7 @@ describe("SessionProjector", () => {
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
@@ -576,24 +467,7 @@ describe("SessionProjector", () => {
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
@@ -643,24 +517,7 @@ describe("SessionProjector", () => {
it.effect("does not infer restart continuation from lifecycle history", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const suspended = () =>
db
@@ -680,24 +537,7 @@ describe("SessionProjector", () => {
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
yield* db
.insert(SessionMessageTable)
.values([
@@ -757,24 +597,7 @@ describe("SessionProjector", () => {
it.effect("projects ended and failed step terminal state", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const endedID = SessionMessage.ID.make("msg_ended")
const failedID = SessionMessage.ID.make("msg_failed")
yield* db
@@ -844,24 +667,7 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
yield* db
.insert(SessionMessageTable)
.values([
+126 -6
View File
@@ -11,6 +11,7 @@ import {
InvalidProviderOutputError,
InvalidRequestError,
RateLimitError,
UnknownProviderError,
} from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
@@ -847,18 +848,19 @@ function* verifyEphemeralDeltas(s: Scenario, kind: FragmentKind) {
function* verifyPartialFlushOnFailure(s: Scenario, kind: FragmentKind) {
const prompt = `Fail after ${kind}`
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
const failure = providerUnavailable()
// A non-retryable failure keeps the step terminal so the flushed fragments settle durably.
const failure = invalidRequest()
yield* s.admit(prompt)
yield* s.llm.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
expect(yield* s.resume.pipe(Effect.flip)).toBe(failure)
expect(yield* s.context).toMatchObject([
Expected.user(prompt),
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
kind === "tool input"
? Expected.failedTool(
{ id: fragmentID(kind, "partial") },
{ error: { type: "provider.transport", message: "Provider unavailable" } },
{ error: { type: "provider.invalid-request", message: "Invalid request" } },
)
: fixture.expectedContent,
]),
@@ -2903,6 +2905,54 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("keeps one durable reasoning part when reasoning closes after text", function* (s) {
yield* s.admit("Think and answer")
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed", index: 0 }]
yield* s.llm.push(
TestLLM.stop(
LLMEvent.reasoningStart({ id: "reasoning-0" }),
LLMEvent.reasoningDelta({ id: "reasoning-0", text: "thinking" }),
LLMEvent.textStart({ id: "text-0" }),
LLMEvent.textDelta({ id: "text-0", text: "Hello" }),
LLMEvent.textDelta({ id: "text-0", text: " world" }),
LLMEvent.reasoningEnd({
id: "reasoning-0",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
LLMEvent.textEnd({ id: "text-0" }),
),
)
yield* s.resume
yield* replaySessionProjection(sessionID)
const assistant = requireAssistant(yield* s.context)
expect(assistant.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Think and answer"),
Expected.assistant({}, [
{
type: "reasoning",
text: "thinking",
state: { reasoningField: "reasoning", reasoningDetails: details },
},
{ type: "text", text: "Hello world" },
]),
])
yield* s.admit("Continue")
yield* s.llm.push([])
yield* s.resume
expect(s.requests[1]?.messages[1]?.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
},
])
})
scenario("restores durable text provider metadata in the next request", function* (s) {
yield* s.admit("Check first")
@@ -3961,7 +4011,8 @@ describe("SessionRunnerLLM", () => {
scenario("awaits started local tools before surfacing provider stream failure", function* (s) {
yield* s.admit("Settle before failing")
const failure = providerUnavailable()
// Non-retryable so the step settles terminally instead of continuing after tool output.
const failure = invalidRequest()
const tools = yield* s.blockTools()
yield* s.llm.push(
TestLLM.failAfter(
@@ -4516,6 +4567,74 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("continues after a mid-stream rate limit honoring retry-after", function* (s) {
yield* s.admit("Continue after rate limit")
yield* s.llm.push(
TestLLM.failAfter(
rateLimited(5_000),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "rate-limited-partial" }),
LLMEvent.textDelta({ id: "rate-limited-partial", text: "Partial" }),
),
)
yield* s.llm.push(TestLLM.text(" continuation", "rate-limit-continuation"))
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages.at(-2)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "Partial" }],
})
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(yield* s.context).toMatchObject([
Expected.user("Continue after rate limit"),
Expected.assistant({ finish: "error", error: { type: "provider.rate-limit" } }, [Expected.text("Partial")]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
])
})
scenario("continues after an unrecognized mid-stream provider failure", function* (s) {
const failure = new AIError({ reason: new UnknownProviderError({ message: "Provider returned error" }) })
yield* s.admit("Continue after unknown failure")
yield* s.llm.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "unknown-failure-partial" }),
LLMEvent.textDelta({ id: "unknown-failure-partial", text: "Partial" }),
),
)
yield* s.llm.push(TestLLM.text(" continuation", "unknown-failure-continuation"))
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* s.context).toMatchObject([
Expected.user("Continue after unknown failure"),
Expected.assistant({ finish: "error", error: { type: "provider.unknown" } }, [Expected.text("Partial")]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
])
})
scenario("lowers interrupted reasoning before continuing an incomplete stream", function* (s) {
yield* s.admit("Continue interrupted reasoning")
yield* s.llm.push(
@@ -5273,7 +5392,8 @@ describe("SessionRunnerLLM", () => {
})
scenario("durably fails a hosted tool left unresolved by a raw provider stream failure", function* (s) {
const failure = providerUnavailable()
// Non-retryable so the step settles terminally instead of continuing after tool output.
const failure = invalidRequest()
yield* s.llm.push(
Stream.concat(
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
@@ -5297,7 +5417,7 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
expect(yield* s.context).toMatchObject([
Expected.user("Fail hosted tool on raw failure"),
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
Expected.failedTool({ id: "call-hosted-raw-failure" }, {}),
]),
])
+24 -121
View File
@@ -198,18 +198,20 @@ beforeEach(() => {
titleStream = successfulTitle
})
const enableTitleAgent = Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
})
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -240,17 +242,8 @@ it.effect("generates a title from the sole user message and renames the session"
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
selectedSmall = small
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_small_model")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Use a small model for this title")
@@ -267,20 +260,12 @@ it.effect("uses a small model from the primary provider", () =>
it.effect("falls back to the primary model when the small model fails", () =>
Effect.gen(function* () {
requests = []
titleStream = () =>
requests.length === 1
? Stream.make(LLMEvent.providerError({ message: "Small model unavailable" }))
: successfulTitle()
selectedSmall = lowSmall
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_small_fallback")
yield* insertSession(
sessionID,
@@ -314,16 +299,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
it.effect("generates from the first user message after later messages exist", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
@@ -342,16 +318,7 @@ it.effect("generates from the first user message after later messages exist", ()
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
@@ -368,16 +335,7 @@ it.effect("retries a legacy persisted fallback title", () =>
it.effect("generates a title for an explicitly requested child session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
@@ -411,8 +369,6 @@ it.effect("generates a title for an explicitly requested child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -429,14 +385,7 @@ it.effect("does not generate when the title agent is removed", () =>
it.effect("regenerates an existing title using the title agent", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Investigate the login failure")
@@ -480,14 +429,7 @@ it.effect("regenerates an existing title using the title agent", () =>
it.effect("bounds regeneration context while preserving the original request and recent conversation", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate_bounded")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, `ORIGINAL_GOAL ${"a".repeat(3_000)} OMITTED_ORIGINAL_END`)
@@ -509,14 +451,7 @@ it.effect("bounds regeneration context while preserving the original request and
it.effect("preserves the existing title when regeneration fails", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate_failure")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Fail to regenerate this title")
@@ -533,15 +468,7 @@ it.effect("preserves the existing title when regeneration fails", () =>
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
@@ -560,14 +487,7 @@ it.effect("retries after a failed title request", () =>
it.effect("does not rename after a failed title stream", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_stream_failure")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Fail this title stream")
@@ -589,16 +509,7 @@ it.effect("does not rename after a failed title stream", () =>
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
// Context hooks shape the agent conversation; title generation is not part of
// it, so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
@@ -621,15 +532,7 @@ it.effect("keeps session context hooks away from title requests", () =>
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
+34
View File
@@ -678,6 +678,40 @@ describe("ReadTool", () => {
}),
)
it.effect("normalizes a zero directory offset in the model heading", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.ListPage({
type: "list-page",
entries: [FileSystem.Entry.make({ path: RelativePath.make("index.ts"), type: "file" })],
truncated: true,
next: 2,
})
const registry = yield* Tool.Service
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory-zero",
name: "read",
input: { path: "src", offset: 0, limit: 1 },
},
})
expect(result.status).toBe("completed")
if (result.status !== "completed") return
expect(result.content).toEqual([
{
type: "text",
text: "Read directory src, entries 1-1\nindex.ts\n[Output truncated. Continue reading with offset: 2]",
},
])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 0, limit: 1 } },
])
}),
)
it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () {
allow = false
+26 -21
View File
@@ -1,10 +1,21 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { z } from "zod"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Tool } from "../src/tool"
import { definition, execute } from "../src/tool/runtime"
const context = {
sessionID: Session.ID.make("ses_tool_schema"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_tool_schema"),
id: Tool.CallID.make("call_tool_schema"),
progress: () => Effect.void,
} satisfies Tool.Context
test("tools are structural values", async () => {
const config = {
name: "foreign",
@@ -136,7 +147,7 @@ test("portable schemas validate and describe typed tools", async () => {
inputSchema: { type: "object", properties: { count: { type: "string" } } },
outputSchema: { type: "string" },
})
const result = await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))
const result = await Effect.runPromise(execute(tool, { count: "41" }, context))
expect(result.output).toBe("42")
})
@@ -166,10 +177,10 @@ test("Zod schemas validate, transform, and describe typed tools", async () => {
additionalProperties: false,
},
})
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { count: "41" }, context))).toMatchObject({
output: { count: 42 },
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
@@ -205,7 +216,7 @@ test("portable schema failures become tool failures", async () => {
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
context,
),
),
)
@@ -228,9 +239,7 @@ test("Effect schema failures use normalized input issues", async () => {
execute: () => Effect.succeed({ content: "unused" }),
}
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
@@ -259,7 +268,7 @@ test("input error prompts limit normalized issues", async () => {
execute: () => Effect.succeed({ content: "unused" }),
}
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
@@ -278,7 +287,7 @@ test("canonical results carry metadata with typed output", async () => {
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
}
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(tool.execute({ value: "out" }, context))).toEqual({
output: { value: "out", internal: true },
metadata: { value: "out" },
content: "out",
@@ -312,33 +321,29 @@ test("raw JSON schemas validate and decode tool input", async () => {
description: "Raw tool",
inputSchema: input,
})
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, context))).toEqual({
output: undefined,
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
@@ -359,10 +364,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => {
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { value: "ok" }, context))).toMatchObject({
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
@@ -381,7 +386,7 @@ test("raw JSON schemas pass input through when they cannot be imported", async (
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, context))).toMatchObject({
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
})
})
+41 -4
View File
@@ -702,6 +702,42 @@ describe("ShellTool ordinary shell syntax", () => {
})
describe("ShellTool", () => {
it.live("returns both parallel CodeMode shell results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
yield* registry.transform((draft) =>
draft.update("shell", (tool) => {
tool.options = { ...tool.options, codemode: true }
}),
)
const command = isWindows ? helloCommand : `${helloCommand}; sleep 0.1`
const inputs = ["one", "two"].map((text) => JSON.stringify({ command: command.replace("hello", text) }))
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-parallel-shells",
name: "execute",
input: { code: `return await Promise.all([tools.shell(${inputs[0]}), tools.shell(${inputs[1]})])` },
},
}).pipe(Effect.timeout("3 seconds"))
expect(result.status).toBe("completed")
expect(JSON.parse(result.output.output)).toEqual([
{ output: "one", exit: 0, truncated: false, status: "completed" },
{ output: "two", exit: 0, truncated: false, status: "completed" },
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
productionIt.live(
"registers and returns real successful output from the active Location",
() =>
@@ -1342,7 +1378,7 @@ describe("ShellTool", () => {
description: bodyExitCommand,
metadata: {
source: "shell",
jobID: "call-background-nonzero",
jobID: shellID,
shellID,
state: "completed",
exit: 7,
@@ -1375,7 +1411,7 @@ describe("ShellTool", () => {
)
: Effect.void,
)
yield* executeTool(registry, {
const settled = yield* executeTool(registry, {
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
// The command can finish while its initial progress update is being published.
progress: (update) =>
@@ -1386,7 +1422,7 @@ describe("ShellTool", () => {
expect(yield* Deferred.await(persisted)).toMatchObject([
{
id: "call-background-silent-nonzero",
id: settled.metadata?.shellID,
status: "completed",
output: "(no output)\n\nCommand exited with code 7.",
},
@@ -1519,9 +1555,10 @@ describe("ShellTool", () => {
yield* Effect.promise(() => Bun.sleep(1))
return yield* backgroundWhenReady(remaining - 1)
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const backgrounded = yield* backgroundWhenReady()
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(backgrounded).toMatchObject([{ id: shellID, type: "shell" }])
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")
+21 -82
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import { spawn } from "child_process"
import fs from "fs/promises"
import path from "path"
import os from "os"
@@ -9,15 +8,13 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
import { runLockWorker, spawnLockWorker, stopLockWorker, waitForFile } from "../fixture/lock-worker"
import { tmpdir } from "../fixture/tmpdir"
function lock(dir: string, key: string) {
return path.join(dir, Hash.fast(key) + ".lock")
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function exists(file: string) {
return fs
.stat(file)
@@ -42,59 +39,8 @@ type Msg = {
done?: string
}
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts")
function run(msg: Msg) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
})
})
}
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
await closed
return
}
await new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
await closed
}
async function waitForFile(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await exists(file)) return
await sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
// ---------------------------------------------------------------------------
// Test layer
// ---------------------------------------------------------------------------
@@ -122,14 +68,13 @@ describe("util.effect-flock", () => {
"acquire and release via scoped Effect",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const lockDir = lock(dir, "eflock:acquire")
yield* Effect.scoped(flock.acquire("eflock:acquire", dir))
expect(yield* Effect.promise(() => exists(lockDir))).toBe(false)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -137,7 +82,7 @@ describe("util.effect-flock", () => {
"supports an acquisition timeout",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:timeout"
@@ -150,7 +95,6 @@ describe("util.effect-flock", () => {
expect(error._tag).toBe("LockTimeoutError")
}),
)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -158,7 +102,7 @@ describe("util.effect-flock", () => {
"withLock data-first",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
let hit = false
@@ -170,7 +114,6 @@ describe("util.effect-flock", () => {
dir,
)
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -178,7 +121,7 @@ describe("util.effect-flock", () => {
"withLock pipeable",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
let hit = false
@@ -186,7 +129,6 @@ describe("util.effect-flock", () => {
hit = true
}).pipe(flock.withLock("eflock:pipe", dir))
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -194,7 +136,7 @@ describe("util.effect-flock", () => {
"writes owner metadata",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:meta"
const file = path.join(lock(dir, key), "meta.json")
@@ -211,7 +153,6 @@ describe("util.effect-flock", () => {
expect(typeof json.createdAt).toBe("string")
}),
)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -219,7 +160,7 @@ describe("util.effect-flock", () => {
"breaks stale lock dirs",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:stale"
const lockDir = lock(dir, key)
@@ -239,7 +180,6 @@ describe("util.effect-flock", () => {
dir,
)
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -247,7 +187,7 @@ describe("util.effect-flock", () => {
"recovers from stale breaker",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:stale-breaker"
const lockDir = lock(dir, key)
@@ -271,7 +211,6 @@ describe("util.effect-flock", () => {
)
expect(hit).toBe(true)
expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -279,7 +218,7 @@ describe("util.effect-flock", () => {
"detects compromise when lock dir removed",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:compromised"
const lockDir = lock(dir, key)
@@ -294,7 +233,6 @@ describe("util.effect-flock", () => {
expect(Exit.isFailure(result)).toBe(true)
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -302,7 +240,7 @@ describe("util.effect-flock", () => {
"detects token mismatch",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:token"
const lockDir = lock(dir, key)
@@ -323,7 +261,6 @@ describe("util.effect-flock", () => {
expect(Exit.isFailure(result)).toBe(true)
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -332,18 +269,18 @@ describe("util.effect-flock", () => {
Effect.gen(function* () {
if (process.platform === "win32") return
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
yield* Effect.promise(async () => {
await fs.mkdir(dir, { recursive: true })
await fs.chmod(dir, 0o500)
})
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(dir, 0o700)))
yield* Effect.promise(() => fs.chmod(dir, 0o500))
const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
// oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
expect(String(result)).toContain("PermissionDenied")
yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
}),
)
@@ -359,7 +296,9 @@ describe("util.effect-flock", () => {
try {
const out = await Promise.all(
Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
Array.from({ length: n }, () =>
runLockWorker(worker, { key: "eflock:stress", dir, done, active, holdMs: 30 } satisfies Msg),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
@@ -385,11 +324,11 @@ describe("util.effect-flock", () => {
const dir = path.join(tmp, "locks")
const ready = path.join(tmp, "ready")
const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
const proc = spawnLockWorker(worker, { key: "eflock:crash", dir, ready, holdMs: 120_000 } satisfies Msg)
try {
await waitForFile(ready, 5_000)
await stopWorker(proc)
await stopLockWorker(proc)
// Backdate lock files so they're past STALE_MS (60s)
const lockDir = lock(dir, "eflock:crash")
@@ -399,11 +338,11 @@ describe("util.effect-flock", () => {
await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
const done = path.join(tmp, "done.log")
const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
const result = await runLockWorker(worker, { key: "eflock:crash", dir, done, holdMs: 10 } satisfies Msg)
expect(result.code).toBe(0)
expect(result.stderr.toString()).toBe("")
} finally {
await stopWorker(proc).catch(() => {})
await stopLockWorker(proc).catch(() => {})
await fs.rm(tmp, { recursive: true, force: true })
}
}),
+39 -112
View File
@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { spawn } from "child_process"
import path from "path"
import os from "os"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { runLockWorker, spawnLockWorker, stopLockWorker, waitForFile } from "../fixture/lock-worker"
import { tmpdir } from "../fixture/tmpdir"
type Msg = {
key: string
@@ -19,29 +19,12 @@ type Msg = {
done?: string
}
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
async function tmpdir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-"))
return {
path: dir,
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
}
function lock(dir: string, key: string) {
return path.join(dir, Hash.fast(key) + ".lock")
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
async function exists(file: string) {
return fs
.stat(file)
@@ -49,73 +32,13 @@ async function exists(file: string) {
.catch(() => false)
}
async function wait(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await exists(file)) return
await sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
function run(msg: Msg) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
})
})
})
}
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
await closed
return
}
await new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
await closed
}
async function readJson<T>(p: string): Promise<T> {
return JSON.parse(await fs.readFile(p, "utf8"))
}
describe("util.flock", () => {
test("enforces mutual exclusion under process contention", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const done = path.join(tmp.path, "done.log")
const active = path.join(tmp.path, "active")
@@ -124,7 +47,7 @@ describe("util.flock", () => {
const out = await Promise.all(
Array.from({ length: n }, () =>
run({
runLockWorker(worker, {
key,
dir,
done,
@@ -132,7 +55,7 @@ describe("util.flock", () => {
holdMs: 30,
staleMs: 1_000,
timeoutMs: 15_000,
}),
} satisfies Msg),
),
)
@@ -147,21 +70,21 @@ describe("util.flock", () => {
}, 20_000)
test("times out while waiting when lock is still healthy", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:timeout"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({
const proc = spawnLockWorker(worker, {
key,
dir,
ready,
holdMs: 20_000,
staleMs: 10_000,
timeoutMs: 30_000,
})
} satisfies Msg)
try {
await wait(ready, 5_000)
await waitForFile(ready, 5_000)
const seen: string[] = []
const err = await Flock.withLock(key, async () => {}, {
dir,
@@ -178,45 +101,49 @@ describe("util.flock", () => {
expect(seen.length).toBeGreaterThan(0)
expect(seen.every((x) => x === key)).toBe(true)
} finally {
await stopWorker(proc).catch(() => undefined)
await stopLockWorker(proc).catch(() => undefined)
}
}, 15_000)
test("recovers after a crashed lock owner", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:crash"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({
const proc = spawnLockWorker(worker, {
key,
dir,
ready,
holdMs: 20_000,
staleMs: 500,
timeoutMs: 30_000,
})
} satisfies Msg)
await wait(ready, 5_000)
await stopWorker(proc)
try {
await waitForFile(ready, 5_000)
await stopLockWorker(proc)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 500,
timeoutMs: 8_000,
},
)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 500,
timeoutMs: 8_000,
},
)
expect(hit).toBe(true)
expect(hit).toBe(true)
} finally {
await stopLockWorker(proc)
}
}, 20_000)
test("breaks stale lock dirs when heartbeat is missing", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:missing-heartbeat"
const lockDir = lock(dir, key)
@@ -242,7 +169,7 @@ describe("util.flock", () => {
})
test("recovers when a stale breaker claim was left behind", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:stale-breaker"
const lockDir = lock(dir, key)
@@ -273,7 +200,7 @@ describe("util.flock", () => {
})
test("fails clearly if lock dir is removed while held", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:compromised"
const lockDir = lock(dir, key)
@@ -313,7 +240,7 @@ describe("util.flock", () => {
})
test("writes owner metadata while lock is held", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:meta"
const file = path.join(lock(dir, key), "meta.json")
@@ -342,7 +269,7 @@ describe("util.flock", () => {
})
test("supports acquire with await using", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:acquire"
const lockDir = lock(dir, key)
@@ -360,7 +287,7 @@ describe("util.flock", () => {
})
test("refuses token mismatch release and recovers from stale", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:token"
const lockDir = lock(dir, key)
@@ -403,7 +330,7 @@ describe("util.flock", () => {
test("fails clearly on unwritable lock roots", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:perm"
+13 -39
View File
@@ -15,6 +15,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { initRepo } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -29,15 +30,6 @@ function abs(input: string) {
const gitWorktree = Worktree.StrategyID.make("git")
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
function setup() {
return Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
@@ -85,9 +77,7 @@ describe("Worktree", () => {
)
yield* Effect.promise(() => initRepo(root.path))
const linked = `${root.path}-linked`
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(linked, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(linked, { recursive: true, force: true })))
yield* Effect.promise(() => $`git worktree add ${linked} -b linked-${Date.now()}`.cwd(root.path).quiet())
const project = yield* Project.Service
@@ -162,9 +152,7 @@ describe("Worktree", () => {
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
const target = abs(path.join(parent, "worktree"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const fiber = yield* bus
.subscribe(Worktree.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -198,9 +186,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* input.db
.update(ProjectTable)
.set({
@@ -284,9 +270,7 @@ describe("Worktree", () => {
const input = yield* setup()
const worktree = yield* Worktree.Service
const parent = abs(`${input.root.path}-branch-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(async () => {
await $`git branch feature-base`.cwd(input.sourceDirectory).quiet()
})
@@ -312,9 +296,7 @@ describe("Worktree", () => {
const input = yield* setup()
const worktree = yield* Worktree.Service
const parent = abs(`${input.root.path}-option-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const error = yield* worktree
.create({
@@ -360,8 +342,8 @@ describe("Worktree", () => {
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
yield* Effect.addFinalizer(() =>
Effect.all([
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })),
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })),
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
@@ -397,9 +379,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
@@ -455,9 +435,7 @@ describe("Worktree", () => {
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
const target = abs(path.join(parent, "worktree-3"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
@@ -487,9 +465,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 10 }, (_, index) =>
@@ -546,7 +522,7 @@ describe("Worktree", () => {
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Promise.all([target, unchanged].map((item) => fs.rm(item, { recursive: true, force: true }))),
).pipe(Effect.ignore),
).pipe(Effect.asVoid),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree add --detach ${unchanged} HEAD`.cwd(input.root.path).quiet())
@@ -594,9 +570,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const stale = abs(`${input.root.path}-worktree-stale`)
const target = abs(`${input.root.path}-worktree-after-stale`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(target, { recursive: true, force: true })))
yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
+21 -7
View File
@@ -5,7 +5,9 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
## Defining A Plugin
@@ -46,12 +48,15 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = "Reviews code for regressions"
item.description = description
item.mode = "subagent"
})
})
@@ -64,8 +69,12 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -81,7 +90,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
event.language = event.sdk.responses(event.model.modelID)
})
```
@@ -94,14 +103,15 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
Promise tools use complete executable tool values with async executors:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add("echo", {
tools.add({
name: "echo",
options: { codemode: false },
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -132,6 +142,10 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+17 -5
View File
@@ -31,7 +31,9 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains:
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
```ts
yield *
@@ -52,8 +54,12 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -72,10 +78,12 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -117,6 +125,10 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```

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