Compare commits

...
Author SHA1 Message Date
Kit Langton be8f5a5242 test(core): simplify registry regression fixtures
Reuse small State and Location fixture constructors, use explicit Effect test layers and virtual time for synthetic providers, and ensure cleanup precedes registration disposal. Preserve regression cases, failure causes, and pre-release shutdown assertions.
2026-08-28 14:22:56 -04:00
Kit Langton ce50f77c20 fix(core): own registry notification lifetimes
Run MCP reconciliation and VCS state-driven refreshes in their layer-owned runtimes so shutdown cancels active and queued work. Contain VCS replay failures at the filesystem event boundary, and share one Deferred per reload debounce window.
2026-08-28 14:22:56 -04:00
Kit Langton 4d57b1d0c9 fix(core): preserve observer lifecycle and branch freshness 2026-08-28 14:22:55 -04:00
Kit Langton c036a8b1b6 fix(core): pull pending registry changes on read 2026-08-28 14:22:55 -04:00
opencode-agent[bot]andrekram1-node 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
123 changed files with 4589 additions and 1430 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 [
{
+99 -34
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,12 @@ 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 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 +320,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 +350,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 +685,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 +693,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 +704,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 +1008,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 +1106,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 +1293,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 +1377,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 +1452,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
@@ -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")
}
+1 -1
View File
@@ -87,7 +87,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+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>> {
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* () {
notify: Effect.fn("Catalog.notify")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+1 -1
View File
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
@@ -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
@@ -25,19 +25,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -56,7 +52,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => current,
current: () => state.get().ignore,
observe,
})
}),
+1 -1
View File
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
find: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
return Service.of({
find: (input) =>
Effect.sync(() => {
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+8 -6
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -402,11 +402,13 @@ const layer = Layer.effect(
}
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
const persistence = yield* Effect.sync(() => {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
return attempt.label ?? implementation?.label?.(exit.value)
}).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
+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
})
+3 -18
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,
}) {
@@ -301,12 +294,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 +385,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) => ({
+39 -20
View File
@@ -5,7 +5,21 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import {
Cause,
Context,
Effect,
Exit,
Fiber,
FiberSet,
Latch,
Layer,
Schema,
Scope,
Semaphore,
Stream,
Types,
} from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -473,11 +487,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 +526,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 +557,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 +571,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 +582,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 +606,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,13 +622,14 @@ 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
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -631,7 +646,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,10 +688,9 @@ 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>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -701,7 +715,12 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
finalize: reconcile,
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
@@ -738,7 +757,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) {
+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,
"",
+53 -42
View File
@@ -26,6 +26,7 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -47,61 +48,71 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map() }),
initial: () => ({ sources: new Map(), materialized: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
name,
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
materialized.set(
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
}
},
notify: () =>
Effect.gen(function* () {
for (const info of state.get().materialized.values()) {
const source = info.source
if (source.type !== "git") continue
yield* cache
.ensure({
reference: Repository.parseRemote(source.repository),
branch: source.branch,
refresh: true,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name: info.name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -111,7 +122,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
return Array.from(state.get().materialized.values())
}),
})
}),
+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"
+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")
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
draft.skills.delete(ID.make(id))
},
}),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+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)))
})
+88 -94
View File
@@ -1,9 +1,9 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
/**
* A replayable transform applied to a draft during reload.
* A replayable transform applied to a draft while deriving state.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
@@ -16,13 +16,14 @@ export interface Registration {
}
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly reloads: Set<Reload>
readonly notifications: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -41,17 +42,24 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* restore(effect)
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
batch.active = false
const notifications = batch.flush
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
: []
// Accepted writes are not rolled back: one failed observer must not hide
// the other states' changes, or replace the batch body's failure.
yield* Exit.asVoidAll([exit, ...notifications])
return yield* exit
}),
)
}
export const inherit = Effect.fnUntraced(function* () {
@@ -65,124 +73,110 @@ export interface Options<State, DraftApi> {
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Synchronously completes derived data after ordered transform replay. */
readonly prepare?: (state: State) => void
/**
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
* Observes accepted changes outside the read path. Batched writes notify at
* batch completion; reloads debounce notifications. Reads never run this hook.
* Resource reconciliation owns its execution scope and coordination.
*/
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
readonly notify?: () => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the latest accepted state, replaying stale inputs synchronously. */
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let requestedAt = 0
let running = false
let closed = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
let pending: Deferred.Deferred<void> | undefined
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
if (options.finalize) yield* options.finalize(options.draft(next))
})
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
// Release scheduling ownership before observers run: an observer may
// request and await another reload without joining this notification.
pending = undefined
yield* notify().pipe(Deferred.into(done))
})
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
yield* Deferred.await(done)
})
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
if (debounce) dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (!batch.flush) {
closed = true
return
}
batch.notifications.add(notify)
return
}
if (!debounce) return yield* restore(notify())
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
// No yields between choosing the burst's completion and claiming it.
const done = pending ?? Deferred.makeUnsafe<void>()
if (!pending) {
pending = done
yield* publish(done).pipe(Effect.forkDetach)
}
yield* restore(Deferred.await(done))
}),
)
return {
get: () => state,
get,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
yield* materialize()
})
}),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
yield* changed(false)
return { dispose }
}),
)
}),
reload,
reload: () => changed(true),
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
finalize: () =>
notify: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
+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
+34 -10
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
@@ -47,8 +47,11 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const vcs = location.vcs
const current: { info: Info } = { info: { branch: {} } }
const refreshLock = Semaphore.makeUnsafe(1)
const scope = {
directory: location.directory,
worktree: location.project.directory,
@@ -69,7 +72,12 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => refresh(),
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(refresh()))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
})
const selected = () => {
const value = state.get()
@@ -87,13 +95,23 @@ const layer = Layer.effect(
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
const changed = yield* Effect.gen(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
return changed
}).pipe(refreshLock.withPermit)
if (!changed) return
// Legacy listeners can publish nested updates before streams and SSE receive
// this event. Re-announce the latest branch if publication was overtaken.
while (true) {
const branch = current.info.branch.current
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
if (branch === current.info.branch.current) return
}
})
if (vcs) {
@@ -105,7 +123,13 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
refresh().pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
),
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
),
),
Effect.forkScoped({ startImmediately: true }),
)
+1 -1
View File
@@ -88,7 +88,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
+53
View File
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -30,6 +31,57 @@ const catalogLayer = AppNodeBuilder.build(
const it = testEffect(catalogLayer)
describe("Catalog", () => {
it.effect("reads available and default models inside a batch before publishing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const observed: string[] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Catalog.Event.Updated.type
? catalog.model.default().pipe(
Effect.map((model) => {
observed.push(model?.id ?? "none")
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const providerID = Provider.ID.make("test")
const old = Model.ID.make("old")
const newest = Model.ID.make("new")
yield* State.batch(
Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(providerID, () => {})
draft.model.update(providerID, old, (model) => {
model.time.released = 1000
})
draft.model.update(providerID, newest, (model) => {
model.time.released = 2000
})
draft.model.default.set(providerID, old)
})
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
expect((yield* catalog.model.default())?.id).toBe(old)
const overlay = yield* catalog.transform((draft) =>
draft.model.update(providerID, old, (model) => {
model.enabled = false
}),
)
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* overlay.dispose
expect((yield* catalog.model.default())?.id).toBe(old)
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([old])
}),
)
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -291,6 +343,7 @@ describe("Catalog", () => {
configured = false
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
+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", () => {
@@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect"
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
describe("LocationWatcherPolicy", () => {
it.effect("reads batched registrations and disposals without notifying observers", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* State.batch(
Effect.gen(function* () {
yield* policy.transform((draft) => draft.add(["base"]))
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
const snapshot = policy.current()
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
yield* overlay.dispose
expect(policy.current()).toEqual(["base"])
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["base"]])
}),
)
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(ignore))
const snapshot = policy.current()
observed.length = 0
ignore = ["second"]
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(snapshot).toEqual(["first"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
}),
)
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(["outer"]))
expect(policy.current()).toEqual(["outer", "inner"])
expect(observed).toEqual([
["outer", "inner"],
["outer", "inner"],
])
}),
)
it.effect("allows an observer to await a reload and keeps later observers current", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
ignore = ["second"]
yield* policy.reload()
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
const writer = yield* policy
.transform((draft) => draft.add(ignore))
.pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(writer)
expect(observed).toEqual([["second"], ["second"]])
}),
)
})
+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")
+5 -1
View File
@@ -14,6 +14,10 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
} satisfies Location.Interface
}
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
}
export const tempLocationLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -21,7 +25,7 @@ export const tempLocationLayer = Layer.unwrap(
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
return locationLayer(ref)
}),
),
)
+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())
+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.",
@@ -0,0 +1,73 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Integration } from "@opencode-ai/core/integration"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
describe("Integration replay", () => {
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("replay-test")
const methodID = Integration.MethodID.make("code")
const source = { fail: false, closed: false }
const failure = new Error("integration transform replay failed")
yield* integrations.transform((editor) => {
if (source.fail) throw failure
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Fixture" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Enter the fixture code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "dummy-access",
refresh: "dummy-refresh",
expires: Number.MAX_SAFE_INTEGER,
}),
),
}),
),
})
})
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
source.fail = true
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
source.fail = false
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
}),
)
const exit = yield* integrations.oauth
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
.pipe(Effect.exit)
expect(exit).toMatchObject(Exit.die(failure))
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
status: "failed",
message: failure.message,
time: attempt.time,
})
expect(source.closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
})
+97
View File
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -262,6 +263,102 @@ describe("Integration", () => {
}),
)
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const method = Integration.OAuthMethod.make({
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "Browser",
})
const expired = Credential.OAuth.make({
type: "oauth",
methodID: method.id,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
const calls: string[] = []
const implementation = {
integrationID,
method,
authorize: () => Effect.die("unexpected authorization"),
refresh: (value: Credential.OAuth) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("original")
return fresh
}),
}
yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) => editor.method.update(implementation))
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(calls).toEqual(["original"])
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original"])
yield* credentials.update(stored.id, { value: expired })
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
const override = yield* integrations.transform((editor) =>
editor.method.update({
...implementation,
refresh: (value) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("override")
return overridden
}),
}),
)
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
yield* override.dispose
yield* credentials.update(stored.id, { value: expired })
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original", "override", "original"])
yield* credentials.update(stored.id, { value: expired })
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original"])
yield* removal.dispose
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
yield* credentials.update(stored.id, { value: expired })
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original", "original"])
const failure = new Error("refresh failed")
yield* integrations.transform((editor) =>
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
)
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
}),
)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+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>()
+193 -12
View File
@@ -33,18 +33,33 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import {
Context,
Deferred,
Effect,
Exit,
Fiber,
Layer,
PubSub,
Ref,
Schedule,
Schema,
Scope,
Sink,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { location, locationLayer } from "./fixture/location"
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
@@ -66,7 +81,7 @@ function resourceServer(
listChanged?: boolean
emptyElicitation?: boolean
urlElicitation?: boolean
respond?: (request: Request) => Response | undefined
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
} = {},
) {
return Effect.acquireRelease(
@@ -158,7 +173,7 @@ function resourceServer(
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return input.respond?.(request) ?? transport.handleRequest(request)
return (await input.respond?.(request)) ?? transport.handleRequest(request)
},
})
return {
@@ -474,6 +489,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")
@@ -1324,6 +1370,126 @@ test("reconciles only changed MCP server config", async () => {
)
})
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const server = yield* resourceServer({
respond: (request) =>
request.method !== "POST"
? undefined
: Effect.runPromise(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
),
})
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
const replacing = yield* service
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const restoring = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(replacing)
yield* Fiber.join(restoring)
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(yield* service.tools()).toEqual([])
expect(server.state.initializations).toBe(1)
}).pipe(
Effect.ensuring(Deferred.succeed(release, undefined)),
Effect.provide(
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
),
)
}),
)
const shutdownIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
[
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Environment.node, hostEnvironmentLayer],
],
),
)
;["active", "queued"].forEach((phase) =>
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
const service = Context.get(context, Mcp.Service)
const observed: string[] = []
let block = false
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== McpEvent.StatusChanged.type) return
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const source = { url: "https://example.com/initial", added: false }
yield* service
.transform((draft) => {
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
})
.pipe(Scope.provide(root))
block = true
source.url = "https://example.com/first"
source.added = phase === "active"
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
source.url = "https://example.com/second"
source.added = true
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
observed.length = 0
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(observed).toEqual([])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
}),
),
)
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1563,7 +1729,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 +1750,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 +1777,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 +1794,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 +1813,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 +1837,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 +1881,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"),
+61 -1
View File
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -103,6 +105,64 @@ describe("Plugin", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const resolved: (Credential.Value | undefined)[] = []
const refreshed: Credential.OAuth[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "oauth-refresh",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.sync(() => {
refreshed.push(value)
return fresh
}),
}),
)
const connection = yield* ctx.integration.connection.active(integrationID)
if (!connection) return yield* Effect.die("stored connection missing")
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
}),
}),
),
])
expect(resolved).toEqual([fresh])
expect(refreshed).toEqual([expired])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+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* () {
+55
View File
@@ -3,6 +3,8 @@ import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -110,6 +112,59 @@ describe("fromPromise", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "dummy",
expires: 0,
})
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
const resolved: string[] = []
const refreshed: string[] = []
const adapted = PluginPromise.fromPromise(
define({
id: "promise-oauth-refresh",
setup: async (ctx) => {
await ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: async () => {
throw new Error("unexpected authorization")
},
refresh: async (value) => {
refreshed.push(value.access)
return fresh
},
}),
)
const connection = await ctx.integration.connection.active(integrationID)
if (!connection) throw new Error("stored connection missing")
const value = await ctx.integration.connection.resolve(connection)
resolved.push(value?.type === "oauth" ? value.access : "missing")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(resolved).toEqual(["fresh"])
expect(refreshed).toEqual(["expired"])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -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),
+133 -9
View File
@@ -1,23 +1,147 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Global } from "@opencode-ai/util/global"
import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { it } from "./lib/effect"
import { it, testEffect } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
const referenceIt = testEffect(referenceLayer)
describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () =>
it.effect("prepares batched references before cache work or update events", () => {
const operations: RepositoryCache.EnsureInput[] = []
const cache = Layer.mock(RepositoryCache.Service, {
ensure: (input) =>
Effect.sync(() => {
operations.push(input)
return {
repository: input.reference.label,
host: input.reference.host,
remote: input.reference.remote,
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
status: "cached",
} satisfies RepositoryCache.Result
}),
})
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
return Effect.gen(function* () {
const references = yield* Reference.Service
const bus = yield* Bus.Service
const observed: string[][] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.name))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* State.batch(
Effect.gen(function* () {
yield* references.transform((draft) => {
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }))
draft.add(
"sdk",
Reference.GitSource.make({
type: "git",
repository: "owner/repo",
branch: "feature/docs",
description: "SDK documentation",
hidden: true,
}),
)
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
draft.add(
"invalid-branch",
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
)
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
})
const infos = yield* references.list()
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
expect(infos[1]).toMatchObject({
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
description: "SDK documentation",
hidden: true,
})
expect(operations).toEqual([])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["docs", "sdk"]])
yield* Effect.yieldNow
expect(
operations.map((input) => ({
repository: input.reference.label,
branch: input.branch,
refresh: input.refresh,
})),
).toEqual([{ repository: "owner/repo", branch: "feature/docs", refresh: true }])
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
})
referenceIt.effect("lets update listeners replace references and refetch the latest projection", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const scope = yield* Scope.make()
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
const first = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== Reference.Event.Updated.type || reentered) return
reentered = true
yield* references
.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
)
.pipe(Scope.provide(scope))
}),
)
const second = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.path))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
yield* references.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
)
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
expect(observed).toEqual([["/new"], ["/new"]])
}),
)
referenceIt.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const parent = yield* Effect.scope
const scope = yield* Scope.fork(parent)
const path = AbsolutePath.make("/docs")
const source = Reference.LocalSource.make({
type: "local",
@@ -33,10 +157,10 @@ describe("Reference", () => {
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}).pipe(Effect.provide(referenceLayer)),
}),
)
it.effect("derives Git paths without exposing cache operations", () =>
referenceIt.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -50,10 +174,10 @@ describe("Reference", () => {
source,
}),
])
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
}),
)
it.effect("preserves configured Git descriptions", () =>
referenceIt.effect("preserves configured Git descriptions", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -72,6 +196,6 @@ describe("Reference", () => {
source,
}),
])
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
}),
)
})
+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)
+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" }, {}),
]),
])
+476 -84
View File
@@ -1,10 +1,18 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
import { it } from "./lib/effect"
const it = testEffect(Layer.empty)
function valuesState(
hooks: Pick<State.Options<{ values: string[] }, { add: (item: string) => void }>, "prepare" | "notify"> = {},
) {
return State.create({
initial: () => ({ values: new Array<string>() }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
...hooks,
})
}
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
@@ -12,17 +20,13 @@ describe("State", () => {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
const state = valuesState({
notify: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const fiber = yield* state
.transform((editor) => {
editor.add("registered")
})
.transform((editor) => editor.add("registered"))
.pipe(Scope.provide(scope), Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
@@ -36,20 +40,16 @@ describe("State", () => {
}),
)
it.effect("commits rebuilt state before finalize runs", () =>
it.effect("makes rebuilt state visible before notifying", () =>
Effect.gen(function* () {
const observed: string[][] = []
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
draft.add("value")
})
yield* state.transform((draft) => draft.add("value"))
// Update events publish from finalize, so consumers reading on the event
// Update events publish from notify, so consumers reading on the event
// must observe the rebuilt state, not the previous one.
expect(observed).toEqual([["value"]])
}),
@@ -58,14 +58,9 @@ describe("State", () => {
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
const state = valuesState()
yield* state.transform((editor) => {
editor.add(value)
})
yield* state.transform((editor) => editor.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
@@ -76,18 +71,327 @@ describe("State", () => {
}),
)
it.effect("reads registrations and disposals inside a batch without publishing", () =>
Effect.gen(function* () {
const observed: string[][] = []
let replays = 0
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
const scope = yield* Scope.make()
yield* State.batch(
Effect.gen(function* () {
yield* state
.transform((draft) => {
replays++
draft.add("value")
})
.pipe(Scope.provide(scope))
const snapshot = state.get()
expect(snapshot.values).toEqual(["value"])
expect(state.get()).toBe(snapshot)
expect(replays).toBe(1)
expect(observed).toEqual([])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
expect(snapshot.values).toEqual(["value"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([[]])
}),
)
it.effect("reads a requested reload without waiting for its notification debounce", () =>
Effect.gen(function* () {
let value = "first"
let replays = 0
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
replays++
draft.add(value)
})
const snapshot = state.get()
observed.length = 0
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("50 millis")
expect(state.get().values).toEqual(["second"])
expect(snapshot.values).toEqual(["first"])
expect(replays).toBe(2)
expect(observed).toEqual([])
yield* TestClock.adjust("450 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
expect(replays).toBe(2)
}),
)
it.effect("can await reload inside a batch while deferring its notification", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
expect(state.get().values).toEqual(["second"])
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("prepares derived data during reads without running observers", () =>
Effect.gen(function* () {
let notifications = 0
const state = State.create({
initial: () => ({ values: [] as string[], joined: "" }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
prepare: (data) => {
data.joined = data.values.join(",")
},
notify: () => Effect.sync(() => notifications++),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("first"))
yield* state.transform((draft) => draft.add("second"))
expect(state.get().joined).toBe("first,second")
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("keeps replay failures observable without replacing the previous snapshot", () =>
Effect.gen(function* () {
let fail = false
const state = valuesState({
prepare: () => {
if (fail) throw new Error("preparation failed")
},
})
yield* state.transform((draft) => draft.add("first"))
const snapshot = state.get()
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("second"))
fail = true
expect(() => state.get()).toThrow("preparation failed")
expect(() => state.get()).toThrow("preparation failed")
expect(snapshot.values).toEqual(["first"])
fail = false
expect(state.get().values).toEqual(["first", "second"])
}),
)
}),
)
it.effect("allows an observer to await a registration on the same state", () =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
let added = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (added) return
added = true
yield* state.transform((draft) => draft.add("second")).pipe(Scope.provide(scope))
}),
})
yield* state.transform((draft) => draft.add("first"))
expect(observed).toEqual([["first"], ["first", "second"]])
expect(state.get().values).toEqual(["first", "second"])
}),
)
it.effect("allows a debounced observer to await another reload", () =>
Effect.gen(function* () {
let value = "first"
let reloadAgain = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!reloadAgain) return
reloadAgain = false
value = "third"
yield* state.reload()
}),
})
yield* state.transform((draft) => draft.add(value))
observed.length = 0
value = "second"
reloadAgain = true
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"], ["third"]])
expect(state.get().values).toEqual(["third"])
}),
)
it.effect("keeps reload waiters associated with their own notification results", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let value = "first"
let block = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
return yield* Effect.die(new Error("first notification failed"))
}),
})
yield* state.transform((draft) => draft.add(value))
// Release the detached worker before the earlier registration finalizer runs.
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
observed.length = 0
value = "second"
block = true
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
value = "third"
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(state.get().values).toEqual(["third"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect(first.pollUnsafe()).toBeUndefined()
expect(observed).toEqual([["second"], ["third"]])
yield* Deferred.succeed(release, undefined)
const exit = yield* Fiber.await(first)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("first notification failed")
}),
)
it.effect("keeps coalesced reload callers independent of cancellation and shares their notification failure", () =>
Effect.gen(function* () {
let fail = false
let notifications = 0
const failure = new Error("notification failed")
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () =>
Effect.sync(() => {
notifications++
if (fail) throw failure
}),
})
yield* state.transform(() => {})
notifications = 0
fail = true
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Fiber.interrupt(cancelled)
yield* TestClock.adjust("500 millis")
const exits = yield* Fiber.awaitAll([first, second])
fail = false
expect(Exit.hasInterrupts(yield* Fiber.await(cancelled))).toBe(true)
expect(exits.map((exit) => Exit.isFailure(exit) && Cause.squash(exit.cause))).toEqual([failure, failure])
expect(notifications).toBe(1)
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(recovered)
expect(notifications).toBe(2)
}),
)
it.effect("continues publishing when a reload caller is cancelled while scheduling its worker", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
let interrupted = false
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add(value))
notifications = 0
value = "second"
const cancelled = yield* Effect.withFiber((fiber) => {
const base = new Scheduler.MixedScheduler("sync")
const scheduler: Scheduler.Scheduler = {
executionMode: base.executionMode,
// Keep the first scheduled task at the detached worker handoff.
shouldYield: () => false,
makeDispatcher: () => {
const dispatcher = base.makeDispatcher()
return {
scheduleTask: (task, priority) => {
if (!interrupted) {
interrupted = true
fiber.interruptUnsafe()
}
dispatcher.scheduleTask(task, priority)
},
flush: () => dispatcher.flush(),
}
},
}
return state.reload().pipe(Effect.provideService(Scheduler.Scheduler, scheduler))
}).pipe(Effect.forkChild({ startImmediately: true }))
const exit = yield* Fiber.await(cancelled)
expect(interrupted).toBe(true)
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(state.get().values).toEqual(["second"])
yield* TestClock.adjust("500 millis")
expect(notifications).toBe(1)
value = "third"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["third"])
expect(notifications).toBe(2)
}),
)
it.effect("disposes a transform once and rebuilds remaining state", () =>
Effect.gen(function* () {
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) => {
editor.add("first")
})
const registration = yield* state.transform((editor) => {
editor.add("second")
})
const state = valuesState()
yield* state.transform((editor) => editor.add("first"))
const registration = yield* state.transform((editor) => editor.add("second"))
expect(state.get().values).toEqual(["first", "second"])
yield* registration.dispose
@@ -98,49 +402,137 @@ describe("State", () => {
}),
)
it.effect("batches automatic rebuilds", () =>
it.effect("batches notifications", () =>
Effect.gen(function* () {
let finalized = 0
const first = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
const second = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
let notifications = 0
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => {
draft.add("first")
})
yield* first.transform((draft) => {
draft.add("second")
})
yield* second.transform((draft) => {
draft.add("third")
})
expect(finalized).toBe(0)
yield* first.transform((draft) => draft.add("first"))
yield* first.transform((draft) => draft.add("second"))
yield* second.transform((draft) => draft.add("third"))
expect(notifications).toBe(0)
}),
)
expect(first.get().values).toEqual(["first", "second"])
expect(second.get().values).toEqual(["third"])
expect(finalized).toBe(2)
expect(notifications).toBe(2)
}),
)
it.effect("closes a batched observer's owning scope without losing the body's failure", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registrations = yield* Scope.make()
const owner = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(Scope.close(owner, Exit.void)),
Effect.andThen(State.batch(Scope.close(registrations, Exit.void), { flush: false })),
),
)
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
const writer = yield* State.batch(
state.transform(() => {}).pipe(Scope.provide(registrations), Effect.andThen(Effect.fail("batch body failed"))),
).pipe(Effect.forkIn(owner, { startImmediately: true }))
yield* Deferred.await(entered)
const shutdown = yield* Scope.close(owner, Exit.void).pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
const exit = yield* Fiber.await(writer)
expect(Exit.hasInterrupts(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("batch body failed")
}),
)
it.effect("lets batch observers read the other states' accepted changes", () =>
Effect.gen(function* () {
const observed: string[][] = []
const first = valuesState({
notify: () => Effect.sync(() => observed.push([...second.get().values])),
})
const second = valuesState()
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => draft.add("first"))
yield* second.transform((draft) => draft.add("second"))
}),
)
expect(observed).toEqual([["second"]])
}),
)
;["replay", "notification"].forEach((failure) =>
it.effect(`notifies the other states when a batch ${failure} fails`, () =>
Effect.gen(function* () {
let fail = true
const observed: string[] = []
const first = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("first")),
})
const failing = State.create({
initial: () => ({}),
draft: (draft) => draft,
prepare: () => {
if (fail && failure === "replay") throw new Error("replay failed")
},
notify: () =>
fail ? Effect.die(new Error("notification failed")) : Effect.sync(() => observed.push("failing")),
})
const last = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("last")),
})
const exit = yield* State.batch(
Effect.gen(function* () {
yield* first.transform(() => {})
yield* failing.transform(() => {})
yield* last.transform(() => {})
return yield* Effect.die(new Error("batch failed"))
}),
).pipe(Effect.exit)
fail = false
expect(observed).toEqual(["first", "last"])
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("batch failed")
expect(Cause.pretty(exit.cause)).toContain(`${failure} failed`)
}
const reload = yield* failing.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual(["first", "last", "failing"])
}),
),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0
let notifications = 0
let prepared = 0
let disposed = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
const state = valuesState({
prepare: () => {
prepared++
},
notify: () => Effect.sync(() => notifications++),
})
const scope = yield* Scope.make()
yield* Scope.addFinalizer(
@@ -148,38 +540,44 @@ describe("State", () => {
Effect.sync(() => disposed++),
)
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(finalized).toBe(1)
const snapshot = state.get()
expect(notifications).toBe(1)
expect(prepared).toBe(1)
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
expect(disposed).toBe(1)
expect(finalized).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(finalized).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
}),
)
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
Effect.gen(function* () {
const finalized: string[] = []
const notifications: string[] = []
const closing = State.create({
initial: () => ({}),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized.push("closing")),
notify: () => Effect.sync(() => notifications.push("closing")),
})
const live = State.create({
initial: () => ({}),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized.push("live")),
notify: () => Effect.sync(() => notifications.push("live")),
})
const scope = yield* Scope.make()
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
finalized.length = 0
notifications.length = 0
yield* State.batch(
Effect.gen(function* () {
@@ -187,33 +585,27 @@ describe("State", () => {
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
}),
)
expect(finalized).toEqual(["live"])
expect(notifications).toEqual(["live"])
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add("value"))
notifications = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
expect(notifications).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
expect(notifications).toBe(1)
}),
)
})
+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
+28 -21
View File
@@ -311,7 +311,7 @@ describe("Tool", () => {
}),
)
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let source: Info[] = []
@@ -321,24 +321,24 @@ describe("Tool", () => {
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
)
@@ -370,7 +370,7 @@ describe("Tool", () => {
}),
)
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
it.effect("batches tool notifications with fresh snapshots and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
@@ -386,7 +386,8 @@ describe("Tool", () => {
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(runs).toEqual(["base", "overlay"])
}).pipe(Scope.provide(scope)),
)
@@ -545,23 +546,32 @@ describe("Tool", () => {
}),
)
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect(output).toEqual([])
return snapshot
}),
)
expect(output).toEqual([
[
@@ -573,9 +583,6 @@ describe("Tool", () => {
},
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})

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