mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8f5a5242 | ||
|
|
ce50f77c20 | ||
|
|
4d57b1d0c9 | ||
|
|
c036a8b1b6 | ||
|
|
f6992059be | ||
|
|
732f949a65 | ||
|
|
cd3b12c579 | ||
|
|
0d6232ffef | ||
|
|
6da20f0efe | ||
|
|
8e25e83e5a | ||
|
|
fe188f8722 | ||
|
|
e4bc8b765b | ||
|
|
d28b6e9ac2 | ||
|
|
ac3cd1b183 | ||
|
|
c601d3b021 | ||
|
|
9bc2165e5c | ||
|
|
374d317412 | ||
|
|
e50c89834e | ||
|
|
f367c202d9 | ||
|
|
9d33d83bb4 | ||
|
|
8f1eff50aa | ||
|
|
e7918e25fd |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -177,7 +177,7 @@ const table = sqliteTable("session", {
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and selects capabilities through `SessionStore` plus `SessionInstance.get(session)` only when a drain starts. The server adapter uses `LocationServiceMap.get(session.location)`; direct SDK bindings retain ready instances without a location map. No layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
+32
-103
@@ -1,6 +1,6 @@
|
||||
export * as Bus from "./bus.js"
|
||||
|
||||
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, type Scope, Stream } from "effect"
|
||||
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||
@@ -125,8 +125,6 @@ export interface Subscribe {
|
||||
* With an ambient Location, delivery is restricted to that Location and global
|
||||
* events. Unlocated Session events use the Session's owner at publication time.
|
||||
* Session moves reach both the old and new Location, without changing the event.
|
||||
* Captured instance views restrict instance-local ephemerals to their private
|
||||
* owner, unless published with `global: true`.
|
||||
*/
|
||||
(): Stream.Stream<Event.Payload>
|
||||
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
|
||||
@@ -148,8 +146,6 @@ export interface Interface {
|
||||
events: I,
|
||||
) => Effect.Effect<PublishResult<I>>
|
||||
readonly subscribe: Subscribe
|
||||
/** Acquire a live Session subscription immediately, retained until the caller's Scope closes. */
|
||||
readonly observe: (sessionID: SessionID) => Effect.Effect<Stream.Stream<SessionEvent.Event>, never, Scope.Scope>
|
||||
/**
|
||||
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
|
||||
* inherited prefix before their first child-authored event. `follow: false`
|
||||
@@ -175,33 +171,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const PrivateOwner = Context.Reference<symbol | undefined>("@opencode/Bus/PrivateOwner", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
/** Bind instance-local ephemeral audiences without replacing the shared durable authority. */
|
||||
export function capture(bus: Interface, owner: symbol): Interface {
|
||||
function subscribe(): Stream.Stream<Event.Payload>
|
||||
function subscribe<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
|
||||
function subscribe<const D extends readonly [Event.Definition, ...Event.Definition[]]>(
|
||||
definitions: D,
|
||||
): Stream.Stream<SubscribePayload<D>>
|
||||
function subscribe(
|
||||
input?: Event.Definition | readonly [Event.Definition, ...Event.Definition[]],
|
||||
): Stream.Stream<Event.Payload> {
|
||||
const stream =
|
||||
input === undefined ? bus.subscribe() : isDefinition(input) ? bus.subscribe(input) : bus.subscribe(input)
|
||||
return stream.pipe(Stream.provideService(PrivateOwner, owner))
|
||||
}
|
||||
|
||||
return {
|
||||
...bus,
|
||||
publish: (definition, data, options) =>
|
||||
bus.publish(definition, data, options).pipe(Effect.provideService(PrivateOwner, owner)),
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
/** Maximum durable rows read per page while replaying or tailing an aggregate log. */
|
||||
@@ -236,36 +205,6 @@ export function configured(options?: Options) {
|
||||
// Keep routing separate from the public event, and retain its snapshot
|
||||
// while a slow subscriber drains events queued before a move or deletion.
|
||||
const routes = new WeakMap<Event.Payload, readonly Location.Ref[]>()
|
||||
const audiences = new WeakMap<Event.Payload, symbol | null>()
|
||||
const localTypes = new Set([
|
||||
"mcp.tools.changed",
|
||||
"mcp.prompts.changed",
|
||||
"mcp.resources.changed",
|
||||
"mcp.status.changed",
|
||||
"plugin.added",
|
||||
"plugin.updated",
|
||||
"config.updated",
|
||||
"agent.updated",
|
||||
"catalog.updated",
|
||||
"integration.updated",
|
||||
"command.updated",
|
||||
"reference.updated",
|
||||
"skill.updated",
|
||||
"websearch.updated",
|
||||
"instruction-discovery.updated",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"form.created",
|
||||
"form.replied",
|
||||
"form.cancelled",
|
||||
"pty.created",
|
||||
"pty.updated",
|
||||
"pty.exited",
|
||||
"pty.deleted",
|
||||
"shell.created",
|
||||
"shell.exited",
|
||||
"shell.deleted",
|
||||
])
|
||||
|
||||
const isSessionEvent = (event: Event.Payload): event is SessionEvent.Event =>
|
||||
Object.hasOwn(SessionEvent.All.cases, event.type)
|
||||
@@ -550,7 +489,7 @@ export function configured(options?: Options) {
|
||||
})
|
||||
}
|
||||
|
||||
const observeListener = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
|
||||
const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
|
||||
Effect.suspend(() => observer(event)).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
@@ -562,7 +501,7 @@ export function configured(options?: Options) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
listeners,
|
||||
(listener) => (isolateListeners ? observeListener(event, listener) : listener(event)),
|
||||
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
|
||||
{ discard: true },
|
||||
)
|
||||
const typed = pubsub.typed.get(event.type)
|
||||
@@ -580,19 +519,18 @@ export function configured(options?: Options) {
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
const event = {
|
||||
id: options?.id ?? Event.ID.create(),
|
||||
created: yield* Clock.currentTimeMillis,
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Event.Payload<D>
|
||||
if (!definition.durable && localTypes.has(definition.type)) {
|
||||
const owner = options?.global ? null : yield* PrivateOwner
|
||||
if (owner !== undefined) audiences.set(event as Event.Payload, owner)
|
||||
}
|
||||
return yield* publishEvent(definition, event, options?.commit)
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
id: options?.id ?? Event.ID.create(),
|
||||
created: yield* Clock.currentTimeMillis,
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Event.Payload<D>,
|
||||
options?.commit,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -793,24 +731,24 @@ export function configured(options?: Options) {
|
||||
|
||||
const local = <A extends Event.Payload>(stream: Stream.Stream<A>) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const owner = yield* PrivateOwner
|
||||
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const matches = (ref: Location.Ref) =>
|
||||
ref.directory === location?.directory && ref.workspaceID === location?.workspaceID
|
||||
return stream.pipe(
|
||||
Stream.filter((event) => {
|
||||
if (!event.durable && localTypes.has(event.type)) {
|
||||
const audience = audiences.get(event)
|
||||
if (audience !== null && audience !== owner) return false
|
||||
}
|
||||
if (!location) return true
|
||||
const refs = routes.get(event)
|
||||
if (refs) return refs.some(matches)
|
||||
return !event.location || matches(event.location)
|
||||
Effect.serviceOption(Location.Service).pipe(
|
||||
Effect.map((location) =>
|
||||
Option.match(location, {
|
||||
onNone: () => stream,
|
||||
onSome: (location) => {
|
||||
const matches = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
return stream.pipe(
|
||||
Stream.filter((event) => {
|
||||
const refs = routes.get(event)
|
||||
if (refs) return refs.some(matches)
|
||||
return !event.location || matches(event.location)
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function subscribe(): Stream.Stream<Event.Payload>
|
||||
@@ -829,14 +767,6 @@ export function configured(options?: Options) {
|
||||
|
||||
const streamLive = (): Stream.Stream<Event.Payload> => local(Stream.fromPubSub(pubsub.live))
|
||||
|
||||
const observe = Effect.fn("Bus.observe")(function* (sessionID: SessionID) {
|
||||
const subscription = yield* PubSub.subscribe(pubsub.live)
|
||||
return Stream.fromSubscription(subscription).pipe(
|
||||
Stream.filter(isSessionEvent),
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
)
|
||||
})
|
||||
|
||||
const readAfter = (
|
||||
aggregateID: string,
|
||||
after: number,
|
||||
@@ -973,7 +903,6 @@ export function configured(options?: Options) {
|
||||
publish,
|
||||
publishAll,
|
||||
subscribe,
|
||||
observe,
|
||||
log,
|
||||
listen,
|
||||
project,
|
||||
|
||||
@@ -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, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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,20 +1,9 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { App } from "./app.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { llmClient, webSocketConstructor } from "./effect/app-node-platform.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
@@ -23,34 +12,24 @@ import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Git } from "./git.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { ModelsDev } from "./models-dev.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { PermissionSaved } from "./permission/saved.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
import { SdkPlugins } from "./plugin/sdk.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Project } from "./project.js"
|
||||
import { ProjectMarkers } from "./project/markers.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { RepositoryCache } from "./repository-cache.js"
|
||||
import { RipgrepBinary } from "./ripgrep/binary.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
@@ -58,10 +37,7 @@ import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillDiscovery } from "./skill/discovery.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
@@ -74,9 +50,6 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
import { WellKnown } from "./wellknown.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
|
||||
export * as Instance from "./instance.js"
|
||||
|
||||
@@ -138,46 +111,6 @@ export const graph = LayerNode.group<typeof nodes>(nodes)
|
||||
export type Services = LayerNode.Output<typeof graph>
|
||||
export type Error = LayerNode.Error<typeof graph>
|
||||
|
||||
const globalNodes = [
|
||||
CrossSpawnSpawner.node,
|
||||
Workspace.node,
|
||||
Watcher.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
RepositoryCache.node,
|
||||
KV.node,
|
||||
AppProcess.node,
|
||||
Npm.node,
|
||||
App.node,
|
||||
llmClient,
|
||||
SessionStore.node,
|
||||
PermissionSaved.node,
|
||||
SdkPlugins.node,
|
||||
RipgrepBinary.node,
|
||||
httpClient,
|
||||
ProjectMarkers.node,
|
||||
ModelsDev.node,
|
||||
SessionEnvironment.node,
|
||||
Git.node,
|
||||
SkillDiscovery.node,
|
||||
Worktree.node,
|
||||
Database.node,
|
||||
webSocketConstructor,
|
||||
// Binding Location introduces Project even though the unbound graph does not.
|
||||
Project.node,
|
||||
] as const satisfies readonly Node.GlobalNode<unknown, unknown>[]
|
||||
|
||||
const globalJobs = new Map([Shell.cleanupNode, ToolOutput.cleanupNode].map((node) => [node.name, node] as const))
|
||||
|
||||
/** Build and configure this graph once in the host scope, before composing instances. */
|
||||
export const globalsGraph = LayerNode.group([...globalNodes, ...globalJobs.values()])
|
||||
|
||||
export type Globals = LayerNode.Output<typeof globalsGraph>
|
||||
export type GlobalsError = LayerNode.Error<typeof globalsGraph>
|
||||
|
||||
export interface Options {
|
||||
// Plugins this instance is born with; empty and absent are equivalent.
|
||||
readonly plugins?: InstancePlugins.List
|
||||
@@ -206,66 +139,6 @@ const vanillaReplacements: LayerNode.Replacements = [
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
|
||||
]
|
||||
|
||||
/**
|
||||
* Reuse already-acquired host infrastructure while giving each instance fresh
|
||||
* local services. Global replacements belong on globalsGraph; local replacements
|
||||
* and a closed per-instance PluginRuntime replacement belong here.
|
||||
*/
|
||||
export function compose<const Items extends LayerNode.Replacements = readonly []>(
|
||||
ref: Location.Ref,
|
||||
options: Omit<Options, "replacements"> & { readonly replacements?: LayerNode.ComposableReplacements<Items> } = {},
|
||||
): Layer.Layer<Services, Error | LayerNode.ReplacementError<Items>, Globals | LayerNode.ReplacementServices<Items>> {
|
||||
const startedAt = performance.now()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...(options.discovery === false ? vanillaReplacements : []),
|
||||
...(options.replacements ?? []),
|
||||
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
|
||||
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
|
||||
]
|
||||
const hoisted = LayerNode.hoist(graph, Node.tags.values.global, replacements).hoisted
|
||||
// PluginRuntime itself is local to a direct instance, but a node replacement
|
||||
// can still depend on shared globals. Inspect those edges before binding them.
|
||||
const boundary = LayerNode.hoist(
|
||||
LayerNode.group(
|
||||
hoisted.dependencies.flatMap((node) => (node.name === PluginRuntime.node.name ? node.dependencies : [node])),
|
||||
),
|
||||
Node.tags.values.global,
|
||||
).hoisted
|
||||
const names = new Set(globalNodes.map((node) => node.name))
|
||||
const unsupported = boundary.dependencies.filter((node) => !names.has(node.name) && !globalJobs.has(node.name))
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(`Unsupported instance globals: ${unsupported.map((node) => node.name).join(", ")}`)
|
||||
}
|
||||
|
||||
return Layer.unwrap(
|
||||
Effect.map(Effect.context<Globals>(), (globals) => {
|
||||
const owner = Symbol()
|
||||
const captured = Layer.succeedContext(
|
||||
globals.pipe(
|
||||
Context.add(Bus.PrivateOwner, owner),
|
||||
Context.add(Bus.Service, Bus.capture(Context.get(globals, Bus.Service), owner)),
|
||||
),
|
||||
)
|
||||
const bindings: LayerNode.Replacements = boundary.dependencies.map((node) => [
|
||||
node,
|
||||
globalJobs.has(node.name) ? Layer.empty : captured,
|
||||
])
|
||||
// Compile the original graph with real closed implementations, not the
|
||||
// dependency-stripped hoist result that cannot honestly be a closed layer.
|
||||
return LayerNode.compile(graph, [...replacements, ...bindings]).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// One instance is one compiled, fresh copy of the graph standing on a directory.
|
||||
export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
const startedAt = performance.now()
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
@@ -49,19 +49,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
|
||||
|
||||
export interface Cell {
|
||||
runtime?: Interface
|
||||
readonly ready?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const makeCell = (ready?: Effect.Effect<void>): Cell => ({ ready })
|
||||
export const makeCell = (): Cell => ({})
|
||||
|
||||
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
||||
(cell.ready ?? Effect.void).pipe(
|
||||
Effect.andThen(() => {
|
||||
const runtime = cell.runtime
|
||||
if (runtime === undefined) return Effect.die(new Error("Plugin runtime is unavailable"))
|
||||
return f(runtime)
|
||||
}),
|
||||
)
|
||||
Effect.suspend(() => {
|
||||
const runtime = cell.runtime
|
||||
if (runtime === undefined) return Effect.die(new Error("Plugin runtime is unavailable"))
|
||||
return f(runtime)
|
||||
})
|
||||
|
||||
const defaultCell = makeCell()
|
||||
|
||||
|
||||
@@ -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,
|
||||
"",
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
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"
|
||||
@@ -27,9 +27,10 @@ import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionInstance } from "./session/instance.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
@@ -98,8 +99,6 @@ type CreateBaseInput = {
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
metadata?: SessionSchema.Metadata
|
||||
/** Runtime discovery policy; never recorded as a Session fact. */
|
||||
discovery?: boolean
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
@@ -343,13 +342,23 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const instances = yield* SessionInstance.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, location))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
})
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
|
||||
@@ -386,7 +395,7 @@ const layer = Layer.effect(
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory, { discovery: input.discovery })
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* persistProject(project)
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
@@ -501,7 +510,7 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
yield* instances.closeTransport(session)
|
||||
yield* closeTransport(session)
|
||||
const children = yield* result.list({ parentID: sessionID })
|
||||
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
||||
yield* environments.clear(sessionID)
|
||||
@@ -649,7 +658,7 @@ const layer = Layer.effect(
|
||||
if (existing) return existing
|
||||
const item = yield* restore(
|
||||
preparePrompt(input, messageID).pipe(
|
||||
Effect.provide(instances.get(session)),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
),
|
||||
)
|
||||
@@ -681,7 +690,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(instances.get(session)))
|
||||
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* generate.generate(input)
|
||||
}),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
@@ -690,7 +699,7 @@ const layer = Layer.effect(
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
@@ -724,7 +733,7 @@ const layer = Layer.effect(
|
||||
metadata: { sessionID: input.sessionID },
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* bus.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
@@ -747,7 +756,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
shell: completed.shell,
|
||||
@@ -765,7 +774,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(instances.get(session)))
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* bus.publish(
|
||||
@@ -828,7 +837,7 @@ const layer = Layer.effect(
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* Location.Service.pipe(
|
||||
Effect.provide(instances.destination(payload.location)),
|
||||
Effect.provide(locations.get(payload.location)),
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
@@ -960,7 +969,7 @@ const layer = Layer.effect(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
}),
|
||||
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
@@ -969,7 +978,7 @@ const layer = Layer.effect(
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
}).pipe(Effect.provide(instances.get(session)))
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* execution.wake(sessionID)
|
||||
return revert
|
||||
}),
|
||||
@@ -1213,7 +1222,7 @@ export const node = makeGlobalNode({
|
||||
Project.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
SessionInstance.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
export * as SessionBindings from "./bindings.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Instance } from "../instance.js"
|
||||
import { Location } from "../location.js"
|
||||
import type { SessionExecution } from "./execution.js"
|
||||
import { SessionInstance } from "./instance.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export class AlreadyBoundError extends Schema.TaggedError<AlreadyBoundError>()("Session.AlreadyBoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class ClosedError extends Schema.TaggedError<ClosedError>()("Session.ClosedError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export interface Binding {
|
||||
readonly check: Effect.Effect<void, ClosedError>
|
||||
readonly activate: (context: Context.Context<Instance.Services>) => Effect.Effect<void>
|
||||
readonly shutdown: (execution: SessionExecution.Interface) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly reserve: (sessionID: SessionSchema.ID) => Effect.Effect<Binding, AlreadyBoundError, Scope.Scope>
|
||||
readonly instances: SessionInstance.Interface
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionBindings") {}
|
||||
|
||||
type Entry = {
|
||||
readonly ids: Set<SessionSchema.ID>
|
||||
context?: Context.Context<Instance.Services>
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const entries = new Map<SessionSchema.ID, Entry>()
|
||||
// Children use their nearest explicitly bound ancestor. Remember every used
|
||||
// child so closing one instance settles its whole execution ownership chain.
|
||||
const find = (session: SessionSchema.Info): Effect.Effect<Entry | undefined> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = entries.get(session.id)
|
||||
if (entry) return Effect.succeed(entry)
|
||||
if (!session.parentID) return Effect.succeed(undefined)
|
||||
return store
|
||||
.get(session.parentID)
|
||||
.pipe(Effect.flatMap((parent) => (parent ? find(parent) : Effect.succeed(undefined))))
|
||||
})
|
||||
const selected = Effect.fn("SessionBindings.selected")(function* (session: SessionSchema.Info) {
|
||||
const entry = yield* find(session)
|
||||
if (!entry || entry.closed || !entry.context)
|
||||
return yield* Effect.die(new Error(`Session has no live bound instance: ${session.id}`))
|
||||
const location = Context.get(entry.context, Location.Service)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.die(new Error(`Bound Session placement changed: ${session.id}`))
|
||||
entry.ids.add(session.id)
|
||||
entries.set(session.id, entry)
|
||||
return entry.context
|
||||
})
|
||||
return Service.of({
|
||||
reserve: (sessionID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
if (yield* find(session)) return yield* new AlreadyBoundError({ sessionID })
|
||||
const entry: Entry = { ids: new Set([sessionID]), closed: false }
|
||||
entries.set(sessionID, entry)
|
||||
const release = Effect.sync(() => {
|
||||
entry.closed = true
|
||||
entry.context = undefined
|
||||
entry.ids.forEach((id) => {
|
||||
if (entries.get(id) === entry) entries.delete(id)
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => release)
|
||||
return {
|
||||
check: Effect.suspend(() => (entry.closed ? Effect.fail(new ClosedError({ sessionID })) : Effect.void)),
|
||||
activate: (context) =>
|
||||
Effect.sync(() => {
|
||||
entry.context = context
|
||||
}),
|
||||
shutdown: (execution) =>
|
||||
Effect.sync(() => {
|
||||
entry.closed = true
|
||||
}).pipe(Effect.andThen(execution.shutdown(Array.from(entry.ids))), Effect.ensuring(release)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
instances: {
|
||||
get: (session) => Layer.effectContext(selected(session)),
|
||||
check: (sessionID) =>
|
||||
store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? selected(session) : Effect.die(new Error(`Session not found: ${sessionID}`)),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
closeTransport: (session) =>
|
||||
selected(session).pipe(
|
||||
Effect.flatMap((context) => Context.get(context, SessionModelTransport.Service).close(session.id)),
|
||||
),
|
||||
destination: () =>
|
||||
Layer.effect(Location.Service, Effect.die(new Error("Direct Sessions do not support movement"))),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node] })
|
||||
|
||||
export const instanceNode = makeGlobalNode({
|
||||
service: SessionInstance.Service,
|
||||
layer: Layer.effect(
|
||||
SessionInstance.Service,
|
||||
Effect.map(Service, (bindings) => bindings.instances),
|
||||
),
|
||||
deps: [node],
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
export * as DirectSession from "./direct.js"
|
||||
|
||||
import { Context, Effect, Exit, Fiber, Latch, Layer, Scope, Stream } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Instance } from "../instance.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PluginRuntime } from "../plugin/runtime.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Session } from "../session.js"
|
||||
import { Shared } from "../shared.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
|
||||
export { AlreadyBoundError, ClosedError } from "./bindings.js"
|
||||
export { ID } from "./schema.js"
|
||||
|
||||
type Facts = Omit<Parameters<Session.Interface["create"]>[0], "id" | "location" | "parentID" | "discovery">
|
||||
export type Options<Items extends Shared.Replacements = Shared.Replacements> = Facts &
|
||||
Omit<Instance.Options, "replacements"> & {
|
||||
readonly replacements?: LayerNode.ComposableReplacements<Items>
|
||||
} & (
|
||||
| { readonly id: SessionSchema.ID; readonly location?: Location.Ref }
|
||||
| { readonly id?: SessionSchema.ID; readonly location: Location.Ref }
|
||||
)
|
||||
|
||||
/** Creates/adopts durable facts, then binds a private ready instance to the caller's Scope. */
|
||||
export const create = Effect.fn("DirectSession.create")(function* <
|
||||
const Items extends Shared.Replacements = readonly [],
|
||||
>(options: Options<Items>) {
|
||||
const shared = yield* Shared.Service
|
||||
const discovery = options.discovery ?? false
|
||||
const session =
|
||||
options.location === undefined
|
||||
? yield* options.id === undefined
|
||||
? Effect.die(new Error("DirectSession.create requires a location or an existing Session ID"))
|
||||
: shared.sessions.get(options.id)
|
||||
: yield* shared.sessions.create({
|
||||
id: options.id,
|
||||
location: options.location,
|
||||
title: options.title,
|
||||
agent: options.agent,
|
||||
model: options.model,
|
||||
metadata: options.metadata,
|
||||
discovery,
|
||||
})
|
||||
// The backing provider may close before the caller's Scope.
|
||||
const scope = yield* Scope.fork(shared.scope)
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(scope, exit))
|
||||
return yield* Effect.gen(function* () {
|
||||
const binding = yield* shared.bindings.reserve(session.id)
|
||||
const ready = yield* Latch.make()
|
||||
const cell = PluginRuntime.makeCell(ready.await)
|
||||
const replacements: Shared.Replacements = [
|
||||
...shared.replacements,
|
||||
...(options.replacements ?? []),
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
]
|
||||
const context = yield* Layer.build(
|
||||
Instance.compose(session.location, { ...options, discovery, replacements }),
|
||||
).pipe(Effect.provideContext(shared.globals))
|
||||
const location = Context.get(context, Location.Service)
|
||||
const info = new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
const bound = <A, E>(effect: Effect.Effect<A, E>) => binding.check.pipe(Effect.orDie, Effect.andThen(effect))
|
||||
const at = <A, E>(ref: Location.Ref, effect: Effect.Effect<A, E>) =>
|
||||
bound(
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
? effect
|
||||
: Effect.die(new Error("Direct instances can only inspect their bound Location")),
|
||||
)
|
||||
cell.runtime = {
|
||||
session: {
|
||||
...shared.sessions,
|
||||
create: (input) => bound(shared.sessions.create({ ...input, discovery })),
|
||||
prompt: (input) => bound(shared.sessions.prompt(input)),
|
||||
synthetic: (input) => bound(shared.sessions.synthetic(input)),
|
||||
command: (input) => bound(shared.sessions.command(input)),
|
||||
generate: (input) => bound(shared.sessions.generate(input)),
|
||||
rename: (input) => bound(shared.sessions.rename(input)),
|
||||
move: (input) => bound(shared.sessions.move(input)),
|
||||
switchAgent: (input) => bound(shared.sessions.switchAgent(input)),
|
||||
switchModel: (input) => bound(shared.sessions.switchModel(input)),
|
||||
},
|
||||
job: shared.jobs,
|
||||
persistentPty: shared.persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
at(
|
||||
ref,
|
||||
Context.get(context, Agent.Service)
|
||||
.list()
|
||||
.pipe(Effect.map((data) => ({ location: info, data }))),
|
||||
),
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) =>
|
||||
at(
|
||||
ref,
|
||||
Context.get(context, Mcp.Service)
|
||||
.servers()
|
||||
.pipe(Effect.map((data) => ({ location: info, data }))),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* binding.activate(context)
|
||||
yield* ready.open
|
||||
const observations = yield* Scope.fork(scope)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
binding.shutdown(shared.execution).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
cell.runtime = undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Context.get(context, PluginSupervisor.Service).flush
|
||||
yield* Context.get(context, McpTool.Service).flush
|
||||
|
||||
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
binding.check.pipe(Effect.andThen(effect.pipe(Effect.forkIn(scope))), Effect.flatMap(Fiber.join))
|
||||
const bus = Context.get(shared.globals, Bus.Service)
|
||||
return {
|
||||
id: session.id,
|
||||
prompt: (input: Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">) =>
|
||||
run(shared.sessions.prompt({ ...input, sessionID: session.id })),
|
||||
resume: () => run(shared.sessions.resume(session.id)),
|
||||
interrupt: () => run(shared.sessions.interrupt(session.id)),
|
||||
wait: () => run(shared.sessions.wait(session.id)),
|
||||
events: {
|
||||
subscribe: <E, R>(callback: (event: SessionEvent.Event) => Effect.Effect<void, E, R>) =>
|
||||
binding.check.pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const caller = yield* Scope.Scope
|
||||
const observer = yield* Scope.fork(observations)
|
||||
const events = yield* bus.observe(session.id).pipe(Scope.provide(observer))
|
||||
return yield* events.pipe(
|
||||
Stream.runForEach(callback),
|
||||
Scope.provide(observer),
|
||||
Effect.onExit((exit) => Scope.close(observer, exit)),
|
||||
Effect.forkIn(observer),
|
||||
Effect.map(Fiber.runIn(caller)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
}).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void)),
|
||||
)
|
||||
})
|
||||
|
||||
export type Handle = Effect.Success<ReturnType<typeof create>>
|
||||
@@ -4,7 +4,7 @@ import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Job } from "../job.js"
|
||||
import { SessionInstance } from "./instance.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionRunCoordinator } from "./run-coordinator.js"
|
||||
@@ -31,11 +31,9 @@ export interface Interface {
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Settles a scoped instance's work without releasing its restart claim. */
|
||||
readonly shutdown: (sessionIDs: readonly SessionSchema.ID[]) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to its host-selected instance's runner. */
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
@@ -48,12 +46,12 @@ export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?:
|
||||
return { type: "failed" as const, error: toSessionError(failure) }
|
||||
}
|
||||
|
||||
/** One process-local coordinator; instance selection is separate from its drain policy. */
|
||||
/** Process-local execution: drains run in this process, routed through the Session's Location graph. */
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const instances = yield* SessionInstance.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -90,7 +88,7 @@ export const layer = Layer.effect(
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(instances.get(session)),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
@@ -162,17 +160,9 @@ export const layer = Layer.effect(
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
return interrupted
|
||||
}),
|
||||
resume: (sessionID) => instances.check(sessionID).pipe(Effect.andThen(coordinator.run(sessionID))),
|
||||
wake: (sessionID) => instances.check(sessionID).pipe(Effect.andThen(coordinator.wake(sessionID))),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
shutdown: (sessionIDs) =>
|
||||
coordinator
|
||||
.interruptAll(sessionIDs, "shutdown")
|
||||
.pipe(
|
||||
Effect.andThen(
|
||||
Effect.forEach(sessionIDs, coordinator.awaitIdle, { concurrency: "unbounded", discard: true }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -180,7 +170,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, SessionInstance.node, Bus.node, Database.node, Job.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
@@ -192,6 +182,5 @@ export const noopLayer = Layer.succeed(
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
shutdown: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
export * as SessionInstance from "./instance.js"
|
||||
|
||||
import { Context, Effect, Layer, RcMap } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Instance } from "../instance.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
|
||||
/** Selects capabilities without owning Session admission or execution coordination. */
|
||||
export interface Interface {
|
||||
readonly get: (session: SessionSchema.Info) => Layer.Layer<Instance.Services, Instance.Error>
|
||||
readonly check: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeTransport: (session: SessionSchema.Info) => Effect.Effect<void>
|
||||
readonly destination: (ref: Location.Ref) => Layer.Layer<Location.Service, Instance.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInstance") {}
|
||||
|
||||
/** The server keeps sharing a graph for each canonical Location. */
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return Service.of({
|
||||
get: (session) => locations.get(session.location),
|
||||
check: () => Effect.void,
|
||||
destination: (ref) => locations.get(ref),
|
||||
closeTransport: Effect.fn("SessionInstance.closeTransport")(function* (session) {
|
||||
const ref = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, ref))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(ref)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [LocationServiceMap.node] })
|
||||
@@ -18,8 +18,6 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* interrupted. Compose with `awaitIdle` for settlement.
|
||||
*/
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
|
||||
/** Marks the whole ownership chain before signaling any of its fibers. */
|
||||
readonly interruptAll: (keys: Iterable<Key>, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -137,42 +135,28 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
start(key, false, scope)
|
||||
})
|
||||
|
||||
const stop = (key: Key, reason?: Reason) => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined || execution.stopping) return undefined
|
||||
if (execution.owner === undefined) {
|
||||
// Settlement window: the owner exited but the settled hook has not finished. The
|
||||
// terminal outcome is already decided, so no reason attaches — but the interrupt
|
||||
// still claims the recorded wakes so settle does not start a dead-intent successor.
|
||||
execution.pendingWake = undefined
|
||||
return undefined
|
||||
}
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||
execution.pendingWake = undefined
|
||||
execution.interruptionReason = reason
|
||||
return execution.owner
|
||||
}
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<boolean> =>
|
||||
Effect.sync(() => {
|
||||
const owner = stop(key, reason)
|
||||
if (owner === undefined) return false
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined || execution.stopping) return false
|
||||
if (execution.owner === undefined) {
|
||||
// Settlement window: the owner exited but the settled hook has not finished. The
|
||||
// terminal outcome is already decided, so no reason attaches — but the interrupt
|
||||
// still claims the recorded wakes so settle does not start a dead-intent successor.
|
||||
execution.pendingWake = undefined
|
||||
return false
|
||||
}
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||
execution.pendingWake = undefined
|
||||
execution.interruptionReason = reason
|
||||
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
|
||||
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
|
||||
fork(Fiber.interrupt(owner))
|
||||
fork(Fiber.interrupt(execution.owner))
|
||||
return true
|
||||
})
|
||||
|
||||
const interruptAll = (keys: Iterable<Key>, reason?: Reason) =>
|
||||
Effect.sync(() => {
|
||||
Array.from(keys)
|
||||
.map((key) => stop(key, reason))
|
||||
.filter((owner) => owner !== undefined)
|
||||
.forEach((owner) => fork(Fiber.interrupt(owner)))
|
||||
})
|
||||
|
||||
// One execution's `done` already spans coalesced continuations; re-check after it
|
||||
// settles to cover a successor execution started by a late doorbell.
|
||||
const awaitIdle = (key: Key): Effect.Effect<void> =>
|
||||
@@ -182,5 +166,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, interruptAll, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
export * as Shared from "./shared.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { App } from "./app.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Job } from "./job.js"
|
||||
import { PersistentPty } from "./persistent-pty.js"
|
||||
import { Session } from "./session.js"
|
||||
import { SessionBindings } from "./session/bindings.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionInstance } from "./session/instance.js"
|
||||
|
||||
export interface Options<Items extends Replacements = Replacements> {
|
||||
readonly database?: Database.Options
|
||||
readonly app?: Partial<App.Info>
|
||||
readonly replacements?: LayerNode.ComposableReplacements<Items>
|
||||
}
|
||||
|
||||
/** Ready constructors accept only fully wired, infallible replacements. */
|
||||
export type Replacements = readonly (readonly [
|
||||
LayerNode.Node<unknown, unknown, LayerNode.Tag | undefined>,
|
||||
LayerNode.Node<unknown, never, LayerNode.Tag | undefined> | Layer.Layer<never>,
|
||||
])[]
|
||||
|
||||
export interface Interface {
|
||||
readonly scope: Scope.Scope
|
||||
readonly globals: Context.Context<Instance.Globals>
|
||||
readonly sessions: Session.Interface
|
||||
readonly bindings: SessionBindings.Interface
|
||||
readonly execution: SessionExecution.Interface
|
||||
readonly jobs: Job.Interface
|
||||
readonly persistentPty: PersistentPty.Interface
|
||||
readonly replacements: Replacements
|
||||
}
|
||||
|
||||
/** Supplied once by the host; contains no location map or embedded HTTP server. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shared") {}
|
||||
|
||||
export function layer<const Items extends Replacements = readonly []>(options: Options<Items> = {}) {
|
||||
const replacements: Replacements = [
|
||||
[
|
||||
Database.node,
|
||||
Database.configured({
|
||||
path:
|
||||
options.database?.path && options.database.path !== ":memory:"
|
||||
? path.resolve(options.database.path)
|
||||
: ":memory:",
|
||||
}),
|
||||
],
|
||||
[App.node, App.configured(options.app)],
|
||||
...(options.replacements ?? []),
|
||||
[SessionInstance.node, SessionBindings.instanceNode],
|
||||
]
|
||||
const configured: LayerNode.Replacements = replacements
|
||||
return LayerNode.compile(
|
||||
LayerNode.group([
|
||||
Instance.globalsGraph,
|
||||
Session.node,
|
||||
SessionBindings.node,
|
||||
SessionExecution.node,
|
||||
Job.node,
|
||||
PersistentPty.node,
|
||||
]),
|
||||
configured,
|
||||
).pipe(
|
||||
Layer.flatMap((context) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.map(Scope.Scope, (scope) => ({
|
||||
scope,
|
||||
globals: context,
|
||||
sessions: Context.get(context, Session.Service),
|
||||
bindings: Context.get(context, SessionBindings.Service),
|
||||
execution: Context.get(context, SessionExecution.Service),
|
||||
jobs: Context.get(context, Job.Service),
|
||||
persistentPty: Context.get(context, PersistentPty.Service),
|
||||
replacements,
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ const cleanupLayer = Layer.effectDiscard(
|
||||
cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped),
|
||||
)
|
||||
|
||||
export const cleanupNode = makeGlobalNode({
|
||||
const cleanupNode = makeGlobalNode({
|
||||
name: "shell-output-cleanup",
|
||||
layer: cleanupLayer,
|
||||
deps: [FSUtil.node, Global.node],
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ const cleanupLayer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
export const cleanupNode = makeGlobalNode({
|
||||
const cleanupNode = makeGlobalNode({
|
||||
name: "tool-output-cleanup",
|
||||
layer: cleanupLayer,
|
||||
deps: [FSUtil.node, Global.node],
|
||||
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
notify: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
|
||||
+34
-10
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Fiber, Scope, Stream } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { IntegrationID } from "@opencode-ai/schema/integration-id"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
|
||||
)
|
||||
const here = Location.Ref.make({ directory: AbsolutePath.make("/capture") })
|
||||
const elsewhere = Location.Ref.make({ directory: AbsolutePath.make("/elsewhere") })
|
||||
|
||||
describe("Bus.capture", () => {
|
||||
;(["wildcard", "typed", "multiple"] as const).forEach((mode) => {
|
||||
it.effect(`restores private ownership for ${mode} streams in foreign and trimmed contexts`, () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Bus.Service
|
||||
const owner = Symbol()
|
||||
const first = Bus.capture(root, Symbol())
|
||||
const second = Bus.capture(root, owner)
|
||||
const foreign = (yield* Effect.context<Scope.Scope>()).pipe(Context.add(Bus.PrivateOwner, owner))
|
||||
const trimmed = foreign.pipe(Context.pick(Scope.Scope))
|
||||
const doneID = Event.ID.create()
|
||||
const watch = (bus: Bus.Interface, context: Context.Context<Scope.Scope>) => {
|
||||
const stream =
|
||||
mode === "wildcard"
|
||||
? bus.subscribe()
|
||||
: mode === "typed"
|
||||
? bus.subscribe(McpEvent.ToolsChanged)
|
||||
: bus.subscribe([McpEvent.ToolsChanged, Plugin.Event.Added])
|
||||
return stream.pipe(
|
||||
Stream.takeUntil((event) => event.id === doneID),
|
||||
Stream.runCollect,
|
||||
Effect.setContext(context),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
expect(Context.get(trimmed, Bus.PrivateOwner)).toBeUndefined()
|
||||
const inForeign = yield* watch(first, foreign)
|
||||
const inTrimmed = yield* watch(first, trimmed)
|
||||
const other = yield* watch(second, trimmed)
|
||||
const shared = yield* watch(root, trimmed)
|
||||
|
||||
const one = yield* first.publish(McpEvent.ToolsChanged, { server: "foreign" }).pipe(Effect.setContext(foreign))
|
||||
const two = yield* first.publish(McpEvent.ToolsChanged, { server: "trimmed" }).pipe(Effect.setContext(trimmed))
|
||||
const added = yield* first.publish(Plugin.Event.Added, { id: Plugin.ID.make("capture-plugin") })
|
||||
const privateOther = yield* second.publish(McpEvent.ToolsChanged, { server: "other" })
|
||||
const unowned = yield* root.publish(McpEvent.ToolsChanged, { server: "shared" })
|
||||
const done = yield* root.publish(McpEvent.ToolsChanged, { server: "done" }, { id: doneID, global: true })
|
||||
|
||||
const expected = mode === "typed" ? [one, two, done] : [one, two, added, done]
|
||||
expect(Array.from(yield* Fiber.join(inForeign))).toEqual(expected)
|
||||
expect(Array.from(yield* Fiber.join(inTrimmed))).toEqual(expected)
|
||||
expect(Array.from(yield* Fiber.join(other))).toEqual([privateOther, done])
|
||||
expect(Array.from(yield* Fiber.join(shared))).toEqual([unowned, done])
|
||||
expect(Object.keys(one).sort()).toEqual(["created", "data", "id", "type"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("honors explicit global audiences and leaves credential notifications shared", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Bus.Service
|
||||
const first = Bus.capture(root, Symbol())
|
||||
const second = Bus.capture(root, Symbol())
|
||||
const doneID = Event.ID.create()
|
||||
const watchers = yield* Effect.forEach([first, second, root], (bus, index) =>
|
||||
bus.subscribe().pipe(
|
||||
Stream.takeUntil((event) => event.id === doneID),
|
||||
Stream.runCollect,
|
||||
Effect.provideService(Location.Service, location(index === 0 ? here : elsewhere)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
),
|
||||
)
|
||||
const broadcast = yield* first
|
||||
.publish(McpEvent.ToolsChanged, { server: "global" }, { global: true, location: here })
|
||||
.pipe(Effect.provideService(Location.Service, location(here)))
|
||||
const updated = yield* first.publish(Credential.Event.Updated, {})
|
||||
const switched = yield* second.publish(Credential.Event.Switched, {
|
||||
integrationID: IntegrationID.make("capture-integration"),
|
||||
credentialID: null,
|
||||
})
|
||||
const done = yield* root.publish(McpEvent.ToolsChanged, { server: "done" }, { id: doneID, global: true })
|
||||
|
||||
expect(broadcast).not.toHaveProperty("location")
|
||||
yield* Effect.forEach(watchers, (fiber) =>
|
||||
Fiber.join(fiber).pipe(
|
||||
Effect.tap((events) =>
|
||||
Effect.sync(() => expect(Array.from(events)).toEqual([broadcast, updated, switched, done])),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps filesystem and VCS notifications placement-scoped rather than private", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Bus.Service
|
||||
const first = Bus.capture(root, Symbol())
|
||||
const second = Bus.capture(root, Symbol())
|
||||
const doneID = Event.ID.create()
|
||||
const watch = (bus: Bus.Interface, ref: Location.Ref) =>
|
||||
bus.subscribe().pipe(
|
||||
Stream.takeUntil((event) => event.id === doneID),
|
||||
Stream.runCollect,
|
||||
Effect.provideService(Location.Service, location(ref)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const local = yield* watch(first, here)
|
||||
const colocated = yield* watch(second, here)
|
||||
const remote = yield* watch(second, elsewhere)
|
||||
const shared = yield* watch(root, here)
|
||||
const changed = yield* first.publish(
|
||||
FileSystem.Event.Changed,
|
||||
{ file: "/capture/file", event: "change" },
|
||||
{ location: here },
|
||||
)
|
||||
const branch = yield* first.publish(VcsEvent.BranchUpdated, { branch: "capture-branch" }, { location: here })
|
||||
const privateEvent = yield* first.publish(McpEvent.ToolsChanged, { server: "private" }, { location: here })
|
||||
const wrongLocation = yield* first.publish(
|
||||
McpEvent.ToolsChanged,
|
||||
{ server: "elsewhere" },
|
||||
{ location: elsewhere },
|
||||
)
|
||||
const done = yield* root.publish(McpEvent.ToolsChanged, { server: "done" }, { id: doneID, global: true })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(local))).toEqual([changed, branch, privateEvent, done])
|
||||
expect(Array.from(yield* Fiber.join(colocated))).toEqual([changed, branch, done])
|
||||
expect(Array.from(yield* Fiber.join(remote))).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(shared))).toEqual([changed, branch, done])
|
||||
expect(wrongLocation.location).toEqual(elsewhere)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delegates durable authority and Session audiences to the shared root", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Bus.Service
|
||||
const first = Bus.capture(root, Symbol())
|
||||
const second = Bus.capture(root, Symbol())
|
||||
;(["publishAll", "observe", "project", "replay", "log", "claim", "remove", "listen"] as const).forEach((key) => {
|
||||
expect(first[key]).toBe(root[key])
|
||||
expect(second[key]).toBe(root[key])
|
||||
})
|
||||
const sessionID = SessionID.create()
|
||||
const observer = yield* second.observe(sessionID)
|
||||
const watchers = yield* Effect.forEach([first, second, root], (bus) =>
|
||||
bus
|
||||
.subscribe(SessionEvent.Renamed)
|
||||
.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped({ startImmediately: true })),
|
||||
)
|
||||
const one = yield* first.publish(SessionEvent.Renamed, { sessionID, title: "first" })
|
||||
const batch = yield* second.publishAll([
|
||||
[SessionEvent.Renamed, { sessionID, title: "second" }],
|
||||
[SessionEvent.Renamed, { sessionID, title: "third" }],
|
||||
])
|
||||
const last = yield* root.publish(SessionEvent.Renamed, { sessionID, title: "fourth" })
|
||||
const events = [one, ...batch, last]
|
||||
|
||||
expect(events.map((event) => event.durable.seq)).toEqual([0, 1, 2, 3].map((seq) => Event.Seq.make(seq)))
|
||||
expect(Array.from(yield* observer.pipe(Stream.take(4), Stream.runCollect))).toEqual(events)
|
||||
yield* Effect.forEach(watchers, (fiber) =>
|
||||
Fiber.join(fiber).pipe(
|
||||
Effect.tap((received) => Effect.sync(() => expect(Array.from(received)).toEqual(events))),
|
||||
),
|
||||
)
|
||||
expect(Array.from(yield* first.log({ aggregateID: sessionID }).pipe(Stream.runCollect))).toEqual([
|
||||
...events,
|
||||
{ type: "log.synced", aggregateID: sessionID, seq: Event.Seq.make(3) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Scope, Stream } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
|
||||
const here = Location.Ref.make({ directory: AbsolutePath.make("/observer") })
|
||||
const elsewhere = Location.Ref.make({ directory: AbsolutePath.make("/publisher") })
|
||||
|
||||
describe("Bus.observe", () => {
|
||||
it.effect("acquires before consumption and filters exact Session events without Location or owner restrictions", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Bus.Service
|
||||
const first = Bus.capture(root, Symbol())
|
||||
const second = Bus.capture(root, Symbol())
|
||||
const sessionID = SessionID.create()
|
||||
yield* second.publish(SessionEvent.Renamed, { sessionID, title: "before observation" }, { location: elsewhere })
|
||||
const observer = yield* first.observe(sessionID).pipe(Effect.provideService(Location.Service, location(here)))
|
||||
|
||||
yield* second.publish(SessionEvent.Renamed, { sessionID: SessionID.create(), title: "other Session" })
|
||||
yield* second.publish(Permission.Event.Asked, {
|
||||
id: Permission.ID.create(),
|
||||
sessionID,
|
||||
action: "read",
|
||||
resources: ["file"],
|
||||
})
|
||||
const renamed = yield* second.publish(
|
||||
SessionEvent.Renamed,
|
||||
{ sessionID, title: "observed" },
|
||||
{ location: elsewhere },
|
||||
)
|
||||
const delta = yield* second.publish(
|
||||
SessionEvent.Text.Delta,
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
ordinal: 0,
|
||||
delta: "queued before consumption",
|
||||
},
|
||||
{ location: elsewhere },
|
||||
)
|
||||
|
||||
const events = yield* observer.pipe(
|
||||
Stream.take(2),
|
||||
Stream.runCollect,
|
||||
Effect.provideService(Location.Service, location(here)),
|
||||
Effect.provideService(Bus.PrivateOwner, Symbol()),
|
||||
)
|
||||
expect(Array.from(events)).toEqual([renamed, delta])
|
||||
expect(renamed.durable.seq).toBe(Event.Seq.make(1))
|
||||
expect(delta).not.toHaveProperty("durable")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets consumer callbacks publish to the same aggregate without deadlocking publication", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = SessionID.create()
|
||||
const observer = yield* bus.observe(sessionID)
|
||||
const received: SessionEvent.Event[] = []
|
||||
const consumer = yield* observer.pipe(
|
||||
Stream.take(2),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
received.push(event)
|
||||
if (event.type === SessionEvent.Renamed.type && event.data.title === "before") {
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: "after" })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: "before" })
|
||||
yield* Fiber.join(consumer)
|
||||
|
||||
expect(received.map((event) => ("durable" in event ? event.durable.seq : undefined))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
Event.Seq.make(1),
|
||||
])
|
||||
expect(
|
||||
received.map((event) => (event.type === SessionEvent.Renamed.type ? event.data.title : undefined)),
|
||||
).toEqual(["before", "after"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes an unconsumed subscription with its acquiring Scope without closing the shared bus", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = SessionID.create()
|
||||
const owner = yield* Scope.Scope
|
||||
const scope = yield* Scope.fork(owner)
|
||||
const observer = yield* bus.observe(sessionID).pipe(Scope.provide(scope))
|
||||
const survivor = yield* bus.observe(sessionID)
|
||||
const before = yield* bus.publish(SessionEvent.Renamed, { sessionID, title: "before disposal" })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
const after = yield* bus.publish(SessionEvent.Renamed, { sessionID, title: "after disposal" })
|
||||
|
||||
expect(Array.from(yield* observer.pipe(Stream.runCollect))).toEqual([])
|
||||
expect(Array.from(yield* survivor.pipe(Stream.take(2), Stream.runCollect))).toEqual([before, after])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ends a blocked consumer when the acquiring Scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const owner = yield* Scope.Scope
|
||||
const scope = yield* Scope.fork(owner)
|
||||
const observer = yield* bus.observe(SessionID.create()).pipe(Scope.provide(scope))
|
||||
const consumer = yield* observer.pipe(Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(consumer))).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { InstancePlugins } from "@opencode-ai/core/plugin/instance"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("Instance Bus capture", () => {
|
||||
it.live("isolates real plugin and MCP notifications across same-directory instances", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const globals = yield* Layer.build(
|
||||
LayerNode.compile(Instance.globalsGraph, [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: false })],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
]),
|
||||
)
|
||||
const root = Context.get(globals, Bus.Service)
|
||||
const doneID = Event.ID.create()
|
||||
const ids = ["capture-first", "capture-second"]
|
||||
const received: EventManifest.ServerEvent[][] = [[], []]
|
||||
const completed = yield* Effect.forEach(ids, () => Deferred.make<void>())
|
||||
const selected = (event: EventManifest.ServerEvent) =>
|
||||
(event.type === "plugin.added" && event.data.id.startsWith("capture-")) ||
|
||||
event.type === "mcp.status.changed" ||
|
||||
event.type === "credential.updated"
|
||||
const shared = yield* root.subscribe().pipe(
|
||||
Stream.filter(EventManifest.isServer),
|
||||
Stream.filter(selected),
|
||||
Stream.takeUntil((event) => event.id === doneID),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
|
||||
const instances = yield* Effect.forEach(ids, (id, index) => {
|
||||
const probe: InstancePlugins.List[number] = {
|
||||
id,
|
||||
effect: (ctx) =>
|
||||
ctx.event.subscribe().pipe(
|
||||
Stream.filter(EventManifest.isServer),
|
||||
Stream.filter(selected),
|
||||
Stream.takeUntil((event) => event.id === doneID),
|
||||
Stream.runForEach((event) => Effect.sync(() => received[index].push(event))),
|
||||
Effect.andThen(Deferred.succeed(completed[index], undefined)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
Effect.asVoid,
|
||||
),
|
||||
}
|
||||
return Layer.build(
|
||||
Instance.compose(ref, { discovery: false, plugins: [probe] }).pipe(
|
||||
Layer.provide(Layer.succeedContext(globals)),
|
||||
),
|
||||
)
|
||||
})
|
||||
yield* Effect.forEach(instances, (instance, index) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Context.get(instance, PluginSupervisor.Service).flush
|
||||
expect(
|
||||
(yield* Context.get(instance, Plugin.Service).list()).find((plugin) => plugin.id === ids[index])?.status,
|
||||
).toBe("active")
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(instances, (instance, index) =>
|
||||
Context.get(instance, Mcp.Service).transform((draft) =>
|
||||
draft.set(ids[index], { type: "local", command: ["unused"], disabled: true }),
|
||||
),
|
||||
)
|
||||
const done = yield* root.publish(Credential.Event.Updated, {}, { id: doneID, global: true })
|
||||
yield* Effect.forEach(completed, Deferred.await)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(shared))).toEqual([done])
|
||||
received.forEach((events, index) =>
|
||||
expect(
|
||||
events.map((event) => {
|
||||
if (event.type === "plugin.added") return [event.type, event.data.id]
|
||||
if (event.type === "mcp.status.changed") return [event.type, event.data.server]
|
||||
return [event.type]
|
||||
}),
|
||||
).toEqual([["plugin.added", ids[index]], ["mcp.status.changed", ids[index]], ["credential.updated"]]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Image } from "../src/image"
|
||||
import { Instance } from "../src/instance"
|
||||
import { Location } from "../src/location"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
|
||||
class External extends Context.Service<External, string>()("test/InstanceExternal") {}
|
||||
class BootError {
|
||||
readonly _tag = "InstanceBootError"
|
||||
}
|
||||
|
||||
const check = () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/") })
|
||||
const open = Instance.compose(ref)
|
||||
const honest: Layer.Layer<Instance.Services, Instance.Error, Instance.Globals> = open
|
||||
// @ts-expect-error Shared infrastructure is required, not secretly booted.
|
||||
const closed: Layer.Layer<Instance.Services, Instance.Error> = open
|
||||
const replacement = Layer.effect(Image.Service, External.pipe(Effect.andThen(Effect.fail(new BootError()))))
|
||||
const advanced = Instance.compose(ref, { replacements: [[Image.node, replacement]] })
|
||||
const requirements: Layer.Layer<Instance.Services, Instance.Error | BootError, Instance.Globals | External> = advanced
|
||||
// @ts-expect-error Raw replacement layers retain their external requirements.
|
||||
const missing: Layer.Layer<Instance.Services, Instance.Error | BootError, Instance.Globals> = advanced
|
||||
// @ts-expect-error Raw replacement layers retain their acquisition errors.
|
||||
const errors: Layer.Layer<Instance.Services, Instance.Error, Instance.Globals | External> = advanced
|
||||
// @ts-expect-error Raw replacements must still provide the original service.
|
||||
Instance.compose(ref, { replacements: [[Image.node, Layer.succeed(External, "wrong output")]] })
|
||||
void [honest, closed, requirements, missing, errors]
|
||||
}
|
||||
void check
|
||||
|
||||
test("instance composition types compile", () => {})
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Exit, Layer, Option, Scope } from "effect"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "../src/agent"
|
||||
import { App } from "../src/app"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Config } from "../src/config"
|
||||
import { Instance } from "../src/instance"
|
||||
import { InstructionDiscovery } from "../src/instruction-discovery"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { InstancePlugins } from "../src/plugin/instance"
|
||||
import { PluginRuntime } from "../src/plugin/runtime"
|
||||
import { PluginSupervisor } from "../src/plugin/supervisor"
|
||||
import { Project } from "../src/project"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Watcher } from "../src/filesystem/watcher"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
class Extra extends Context.Service<Extra, string>()("test/InstanceExtraGlobal") {}
|
||||
|
||||
describe("Instance.compose", () => {
|
||||
it.live("reuses configured globals across fresh, separately bound local graphs", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const acquired = { global: 0, app: 0, project: 0, runtime: 0 }
|
||||
const released = { global: 0 }
|
||||
const profile = [
|
||||
[
|
||||
Global.node,
|
||||
Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
acquired.global++
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => released.global++))
|
||||
return yield* Layer.build(tempGlobalLayer)
|
||||
}),
|
||||
),
|
||||
],
|
||||
[
|
||||
App.node,
|
||||
Layer.effect(
|
||||
App.Metadata,
|
||||
Effect.sync(() => {
|
||||
acquired.app++
|
||||
return App.make({ name: "compose-host", version: "test" })
|
||||
}),
|
||||
),
|
||||
],
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: false })],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
] as const
|
||||
const replacements = [
|
||||
...profile,
|
||||
[
|
||||
Project.node,
|
||||
Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
acquired.project++
|
||||
return yield* Layer.build(LayerNode.compile(Project.node, profile))
|
||||
}),
|
||||
),
|
||||
],
|
||||
] as const
|
||||
const owner = yield* Effect.scope
|
||||
const sharedScope = yield* Scope.fork(owner)
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const globals = yield* Layer.buildWithMemoMap(
|
||||
LayerNode.compile(Instance.globalsGraph, replacements),
|
||||
memoMap,
|
||||
sharedScope,
|
||||
)
|
||||
expect(acquired).toEqual({ global: 1, app: 1, project: 1, runtime: 0 })
|
||||
expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service).pipe(Effect.provide(globals)))).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
const plugin = Plugin.define({
|
||||
id: "compose-plugin",
|
||||
effect: (ctx) => ctx.agent.transform((agents) => agents.update(Agent.ID.make("compose-agent"), () => {})),
|
||||
})
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
|
||||
const local = [
|
||||
...replacements,
|
||||
[
|
||||
Config.node,
|
||||
Config.configured({ project: false, global: false, content: JSON.stringify({ shell: "compose-shell" }) }),
|
||||
],
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: true, global: false })],
|
||||
[
|
||||
Location.node,
|
||||
Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make("/") }), { discovery: false }),
|
||||
],
|
||||
[InstancePlugins.node, InstancePlugins.bound([plugin])],
|
||||
] as const
|
||||
const build = (scope: Scope.Scope, plugins: InstancePlugins.List) =>
|
||||
Layer.buildWithMemoMap(
|
||||
Instance.compose(ref, {
|
||||
discovery: false,
|
||||
plugins,
|
||||
replacements: [
|
||||
...local,
|
||||
[
|
||||
PluginRuntime.node,
|
||||
Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
acquired.runtime++
|
||||
return yield* Layer.build(PluginRuntime.layerWithCell(PluginRuntime.makeCell()))
|
||||
}),
|
||||
),
|
||||
],
|
||||
],
|
||||
}).pipe(Layer.provide(Layer.succeedContext(globals))),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
const firstScope = yield* Scope.fork(owner)
|
||||
const secondScope = yield* Scope.fork(owner)
|
||||
const first = yield* build(firstScope, [plugin])
|
||||
const second = yield* build(secondScope, [])
|
||||
yield* Context.get(first, PluginSupervisor.Service).flush
|
||||
yield* Context.get(second, PluginSupervisor.Service).flush
|
||||
|
||||
expect(acquired).toEqual({ global: 1, app: 1, project: 1, runtime: 2 })
|
||||
expect(Context.get(first, Location.Service).directory).toBe(ref.directory)
|
||||
expect(Context.get(second, Location.Service).directory).toBe(ref.directory)
|
||||
expect(Context.get(first, Config.Service)).not.toBe(Context.get(second, Config.Service))
|
||||
expect(Context.get(first, Agent.Service)).not.toBe(Context.get(second, Agent.Service))
|
||||
expect(Config.latest(yield* Context.get(first, Config.Service).entries(), "shell")).toBe("compose-shell")
|
||||
expect(Context.get(first, InstructionDiscovery.Service).project).toBe(true)
|
||||
expect(
|
||||
Context.get(first, InstancePlugins.Service)
|
||||
.all()
|
||||
.map((item) => item.id),
|
||||
).toEqual([plugin.id])
|
||||
expect(Context.get(second, InstancePlugins.Service).all()).toEqual([])
|
||||
expect(yield* Context.get(first, Agent.Service).get(Agent.ID.make("compose-agent"))).toBeDefined()
|
||||
expect(yield* Context.get(second, Agent.Service).get(Agent.ID.make("compose-agent"))).toBeUndefined()
|
||||
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
expect(released.global).toBe(0)
|
||||
expect((yield* Context.get(second, Agent.Service).list()).length).toBeGreaterThan(0)
|
||||
yield* Context.get(globals, Project.Service).list()
|
||||
yield* Scope.close(secondScope, Exit.void)
|
||||
yield* Scope.close(sharedScope, Exit.void)
|
||||
expect(released.global).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects globals introduced by a local replacement instead of acquiring them fresh", () => {
|
||||
const extra = Node.makeGlobalNode({ service: Extra, layer: Layer.succeed(Extra, "extra"), deps: [] })
|
||||
const discovery = Node.makeLocationNode({
|
||||
service: InstructionDiscovery.Service,
|
||||
layer: InstructionDiscovery.layer().pipe(Layer.tap(() => Extra)),
|
||||
deps: [Bus.node, extra],
|
||||
})
|
||||
expect(() =>
|
||||
Instance.compose(Location.Ref.make({ directory: AbsolutePath.make("/") }), {
|
||||
replacements: [[InstructionDiscovery.node, discovery]],
|
||||
}),
|
||||
).toThrow("Unsupported instance globals: test/InstanceExtraGlobal")
|
||||
})
|
||||
|
||||
test("also checks shared dependencies of a per-instance runtime replacement", () => {
|
||||
const extra = Node.makeGlobalNode({ service: Extra, layer: Layer.succeed(Extra, "extra"), deps: [] })
|
||||
const runtime = Node.makeGlobalNode({
|
||||
service: PluginRuntime.Service,
|
||||
layer: PluginRuntime.layerWithCell(PluginRuntime.makeCell()).pipe(Layer.tap(() => Extra)),
|
||||
deps: [extra],
|
||||
})
|
||||
expect(() =>
|
||||
Instance.compose(Location.Ref.make({ directory: AbsolutePath.make("/") }), {
|
||||
replacements: [[PluginRuntime.node, runtime]],
|
||||
}),
|
||||
).toThrow("Unsupported instance globals: test/InstanceExtraGlobal")
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -33,16 +33,31 @@ 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, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import type { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -88,7 +88,7 @@ describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = "pty_missing" as PtyID
|
||||
const id = PtyID.make("pty_missing")
|
||||
|
||||
for (const result of [
|
||||
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
|
||||
|
||||
@@ -1,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)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionInstance } from "@opencode-ai/core/session/instance"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
@@ -380,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" }])
|
||||
|
||||
@@ -414,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",
|
||||
},
|
||||
@@ -1308,8 +1307,7 @@ function buildExecution(
|
||||
return yield* Layer.buildWithScope(
|
||||
SessionRestart.layer(options).pipe(
|
||||
Layer.provideMerge(sessionLayer),
|
||||
// Capture the fixture's Location map instead of Session.node's memoized adapter.
|
||||
Layer.provideMerge(Layer.fresh(SessionExecution.layer.pipe(Layer.provide(SessionInstance.layer)))),
|
||||
Layer.provideMerge(Layer.fresh(SessionExecution.layer)),
|
||||
Layer.provide(Layer.succeed(Database.Service, database)),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
|
||||
@@ -42,7 +42,6 @@ const it = testEffect(
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
shutdown: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -56,7 +56,6 @@ const execution = Layer.succeed(
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
awaitIdle: () => Effect.void,
|
||||
shutdown: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
|
||||
@@ -129,10 +129,6 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
wake: coordinator.wake,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
shutdown: (sessionIDs) =>
|
||||
coordinator
|
||||
.interruptAll(sessionIDs)
|
||||
.pipe(Effect.andThen(Effect.forEach(sessionIDs, coordinator.awaitIdle, { discard: true }))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer(llmClient)))
|
||||
|
||||
@@ -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"
|
||||
@@ -449,10 +450,6 @@ const layer = Layer.unwrap(
|
||||
wake: coordinator.wake,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
shutdown: (sessionIDs) =>
|
||||
coordinator
|
||||
.interruptAll(sessionIDs)
|
||||
.pipe(Effect.andThen(Effect.forEach(sessionIDs, coordinator.awaitIdle, { discard: true }))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(runnerLayer))
|
||||
@@ -851,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,
|
||||
]),
|
||||
@@ -4013,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(
|
||||
@@ -4568,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(
|
||||
@@ -5325,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")]),
|
||||
@@ -5349,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" }, {}),
|
||||
]),
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])))
|
||||
})
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { z } from "zod"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { Tool } from "../src/tool"
|
||||
import { definition, execute } from "../src/tool/runtime"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_tool_schema"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_tool_schema"),
|
||||
id: Tool.CallID.make("call_tool_schema"),
|
||||
progress: () => Effect.void,
|
||||
} satisfies Tool.Context
|
||||
|
||||
test("tools are structural values", async () => {
|
||||
const config = {
|
||||
name: "foreign",
|
||||
@@ -136,7 +147,7 @@ test("portable schemas validate and describe typed tools", async () => {
|
||||
inputSchema: { type: "object", properties: { count: { type: "string" } } },
|
||||
outputSchema: { type: "string" },
|
||||
})
|
||||
const result = await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))
|
||||
const result = await Effect.runPromise(execute(tool, { count: "41" }, context))
|
||||
expect(result.output).toBe("42")
|
||||
})
|
||||
|
||||
@@ -166,10 +177,10 @@ test("Zod schemas validate, transform, and describe typed tools", async () => {
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
|
||||
expect(await Effect.runPromise(execute(tool, { count: "41" }, context))).toMatchObject({
|
||||
output: { count: 42 },
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -205,7 +216,7 @@ test("portable schema failures become tool failures", async () => {
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
},
|
||||
1,
|
||||
{} as Tool.Context,
|
||||
context,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -228,9 +239,7 @@ test("Effect schema failures use normalized input issues", async () => {
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -259,7 +268,7 @@ test("input error prompts limit normalized issues", async () => {
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -278,7 +287,7 @@ test("canonical results carry metadata with typed output", async () => {
|
||||
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
|
||||
expect(await Effect.runPromise(tool.execute({ value: "out" }, context))).toEqual({
|
||||
output: { value: "out", internal: true },
|
||||
metadata: { value: "out" },
|
||||
content: "out",
|
||||
@@ -312,33 +321,29 @@ test("raw JSON schemas validate and decode tool input", async () => {
|
||||
description: "Raw tool",
|
||||
inputSchema: input,
|
||||
})
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, context))).toEqual({
|
||||
output: undefined,
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -359,10 +364,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => {
|
||||
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok" }, context))).toMatchObject({
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -381,7 +386,7 @@ test("raw JSON schemas pass input through when they cannot be imported", async (
|
||||
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
|
||||
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, context))).toMatchObject({
|
||||
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -117,7 +117,6 @@ const executionNode = makeGlobalNode({
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||
shutdown: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -703,6 +702,42 @@ describe("ShellTool ordinary shell syntax", () => {
|
||||
})
|
||||
|
||||
describe("ShellTool", () => {
|
||||
it.live("returns both parallel CodeMode shell results", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* registry.transform((draft) =>
|
||||
draft.update("shell", (tool) => {
|
||||
tool.options = { ...tool.options, codemode: true }
|
||||
}),
|
||||
)
|
||||
const command = isWindows ? helloCommand : `${helloCommand}; sleep 0.1`
|
||||
const inputs = ["one", "two"].map((text) => JSON.stringify({ command: command.replace("hello", text) }))
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-parallel-shells",
|
||||
name: "execute",
|
||||
input: { code: `return await Promise.all([tools.shell(${inputs[0]}), tools.shell(${inputs[1]})])` },
|
||||
},
|
||||
}).pipe(Effect.timeout("3 seconds"))
|
||||
expect(result.status).toBe("completed")
|
||||
expect(JSON.parse(result.output.output)).toEqual([
|
||||
{ output: "one", exit: 0, truncated: false, status: "completed" },
|
||||
{ output: "two", exit: 0, truncated: false, status: "completed" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
productionIt.live(
|
||||
"registers and returns real successful output from the active Location",
|
||||
() =>
|
||||
@@ -1343,7 +1378,7 @@ describe("ShellTool", () => {
|
||||
description: bodyExitCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-background-nonzero",
|
||||
jobID: shellID,
|
||||
shellID,
|
||||
state: "completed",
|
||||
exit: 7,
|
||||
@@ -1376,7 +1411,7 @@ describe("ShellTool", () => {
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* executeTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
|
||||
// The command can finish while its initial progress update is being published.
|
||||
progress: (update) =>
|
||||
@@ -1387,7 +1422,7 @@ describe("ShellTool", () => {
|
||||
|
||||
expect(yield* Deferred.await(persisted)).toMatchObject([
|
||||
{
|
||||
id: "call-background-silent-nonzero",
|
||||
id: settled.metadata?.shellID,
|
||||
status: "completed",
|
||||
output: "(no output)\n\nCommand exited with code 7.",
|
||||
},
|
||||
@@ -1520,9 +1555,10 @@ describe("ShellTool", () => {
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* backgroundWhenReady(remaining - 1)
|
||||
})
|
||||
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
||||
const backgrounded = yield* backgroundWhenReady()
|
||||
const settled = yield* Fiber.join(waiting)
|
||||
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
||||
expect(backgrounded).toMatchObject([{ id: shellID, type: "shell" }])
|
||||
expect(settled.metadata).toMatchObject({ truncated: false })
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ const executionNode = makeGlobalNode({
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
shutdown: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
+371
-90
@@ -2,35 +2,40 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
|
||||
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { locationLayer } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { it, testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
|
||||
|
||||
const synthetic = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node]), [
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
]),
|
||||
)
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
locationLayer(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
],
|
||||
]),
|
||||
@@ -84,40 +89,36 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
|
||||
}) satisfies VcsDefinition
|
||||
|
||||
describe("Vcs", () => {
|
||||
it.live("returns empty results outside version control", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.branches()).toEqual([])
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
synthetic.effect("returns empty results outside version control", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.branches()).toEqual([])
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves scoped providers and restores the fallback after disposal", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
synthetic.effect("serves scoped providers and restores the fallback after disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
expect(yield* vcs.diff("working")).toEqual([
|
||||
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
expect(yield* vcs.diff("working")).toEqual([
|
||||
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("automatically selects a provider matching the resolved repository", () =>
|
||||
@@ -133,80 +134,360 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("passes location scope and bounded diff options to providers", () =>
|
||||
withTmp((directory) =>
|
||||
synthetic.effect("passes location scope and bounded diff options to providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: VcsDiffInput[] = []
|
||||
const vcs = yield* Vcs.Service
|
||||
const location = yield* Location.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
diff: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
yield* vcs.diff("branch", { context: 3 })
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
directory: location.directory,
|
||||
worktree: location.directory,
|
||||
canonical: location.directory,
|
||||
mode: "branch",
|
||||
context: 3,
|
||||
maxOutputBytes: 10_000_000,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("validates provider results and bounds oversized patches", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
diff: () =>
|
||||
Effect.succeed([
|
||||
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
const rows = yield* vcs.diff("working")
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
|
||||
expect(rows[0].additions).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("preserves provider interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
let interrupt = false
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
info: () =>
|
||||
interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature", default: "main" } }),
|
||||
status: () => Effect.never,
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
const fiber = yield* Effect.forkChild(vcs.status())
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
|
||||
interrupt = true
|
||||
const reload = yield* vcs.reload().pipe(Effect.timeout("1 second"), Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
const reloaded = yield* Fiber.await(reload)
|
||||
expect(Exit.isFailure(reloaded) && Cause.hasInterruptsOnly(reloaded.cause)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps watching HEAD changes after a transform replay failure", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const observed: VcsDiffInput[] = []
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
const bus = yield* Bus.Service
|
||||
const replayed = yield* Deferred.make<void>()
|
||||
const faulty = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
|
||||
let branch = "initial"
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
|
||||
)
|
||||
const failure = new Error("fixture replay failed")
|
||||
let replays = 0
|
||||
const failed = yield* vcs
|
||||
.transform(() => {
|
||||
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
|
||||
throw failure
|
||||
})
|
||||
.pipe(Scope.provide(faulty), Effect.exit)
|
||||
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
|
||||
yield* Effect.yieldNow
|
||||
const status = yield* vcs.status().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
|
||||
expect((yield* vcs.info()).branch.current).toBe("initial")
|
||||
|
||||
branch = "recovered"
|
||||
yield* Scope.close(faulty, Exit.void)
|
||||
expect((yield* vcs.info()).branch.current).toBe("recovered")
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
|
||||
branch = "after-recovery"
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
const event = yield* Fiber.join(updated)
|
||||
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
|
||||
expect(Option.getOrUndefined(event)).toMatchObject({ data: { branch: "after-recovery" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("stops in-flight and queued reloads when 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(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
]),
|
||||
root,
|
||||
)
|
||||
const vcs = Context.get(context, Vcs.Service)
|
||||
const reads: string[] = []
|
||||
const observed: (string | undefined)[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== VcsEvent.BranchUpdated.type) return
|
||||
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
|
||||
}),
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let branch = "initial"
|
||||
let block = false
|
||||
yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
diff: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
const value = branch
|
||||
reads.push(value)
|
||||
if (block) {
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { branch: { current: value } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
observed.length = 0
|
||||
|
||||
yield* vcs.diff("branch", { context: 3 })
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
directory,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
mode: "branch",
|
||||
context: 3,
|
||||
maxOutputBytes: 10_000_000,
|
||||
},
|
||||
])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
block = true
|
||||
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered).pipe(Effect.timeout("1 second"), TestClock.withLive)
|
||||
branch = "late"
|
||||
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Effect.yieldNow
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
const snapshot = yield* vcs.info()
|
||||
|
||||
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)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(observed).toEqual([])
|
||||
expect(yield* vcs.info()).toBe(snapshot)
|
||||
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
|
||||
)
|
||||
|
||||
it.live("validates provider results and bounds oversized patches", () =>
|
||||
withTmp((directory) =>
|
||||
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
const bus = yield* Bus.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const reads: string[] = []
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
diff: () =>
|
||||
Effect.succeed([
|
||||
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
reads.push(reads.length === 0 ? "initial" : "filesystem")
|
||||
if (reads.length === 1) return { branch: { current: "initial" } }
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return { branch: { current: "filesystem" } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
),
|
||||
)
|
||||
const updates = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
const rows = yield* vcs.diff("working")
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
|
||||
expect(rows[0].additions).toBe(1)
|
||||
}).pipe(provide(directory)),
|
||||
yield* Effect.gen(function* () {
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(started)
|
||||
const configured = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push("config")
|
||||
return { branch: { current: "config" } }
|
||||
}),
|
||||
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect(reads).toEqual(["initial", "filesystem"])
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves provider interruption", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Effect.scope
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (
|
||||
event.type !== VcsEvent.BranchUpdated.type ||
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
|
||||
)
|
||||
return Effect.void
|
||||
return vcs
|
||||
.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
|
||||
)
|
||||
.pipe(Scope.provide(scope), Effect.asVoid)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider({ status: () => Effect.never }))
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
}),
|
||||
)
|
||||
|
||||
const fiber = yield* Effect.forkChild(vcs.status())
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === VcsEvent.BranchUpdated.type &&
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
|
||||
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const older = yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
|
||||
)
|
||||
expect(older.pollUnsafe()).toBeUndefined()
|
||||
expect((yield* vcs.info()).branch.current).toBe("newer")
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(older)
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("lists local branches by recent activity", () =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user