Compare commits

..
Author SHA1 Message Date
opencode-agent[bot]andrekram1-node f6992059be fix(core): isolate shell jobs by shell ID (#45985)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-28 13:13:16 -05:00
Kit Langton 732f949a65 fix(tui): admit compaction requests optimistically (#45973)
Render queued compaction feedback before model setup, coalesce repeated gestures, and reconcile canonical admissions without restoring consumed rows. Serialize prompt and compaction preparation through the existing per-session admission chain.
2026-08-28 14:00:09 -04:00
James Long cd3b12c579 feat(tui): resize terminal panes (#45979) 2026-08-28 13:47:50 -04:00
Kit Langton 0d6232ffef test(core): share git test fixtures (#45640) 2026-08-28 13:37:42 -04:00
Kit Langton 6da20f0efe chore(protocol): refresh committed OpenAPI baseline (#45969) 2026-08-28 13:37:11 -04:00
Aiden Cline 8e25e83e5a fix(core): continue retryable failures after durable output (#45861) 2026-08-28 12:35:00 -05:00
Kit Langton fe188f8722 fix(core): normalize directory page headings (#45641) 2026-08-28 13:29:41 -04:00
Kit Langton e4bc8b765b refactor(core): reuse MCP prompt helpers (#45660) 2026-08-28 13:26:51 -04:00
Kit Langton d28b6e9ac2 test(core): construct valid tool contexts (#45657) 2026-08-28 13:26:41 -04:00
Kit Langton ac3cd1b183 test(core): simplify provider smoke tests (#45656) 2026-08-28 13:26:27 -04:00
Kit Langton c601d3b021 refactor(core): share provider factory loading (#45654) 2026-08-28 13:26:15 -04:00
Kit Langton 9bc2165e5c refactor(core): reuse lazy shell parser initialization (#45651) 2026-08-28 13:26:04 -04:00
Kit Langton 374d317412 test(core): simplify provider test setup (#45635) 2026-08-28 13:25:53 -04:00
Kit Langton e50c89834e refactor(core): reuse plugin response wrapper (#45648) 2026-08-28 13:25:40 -04:00
Kit Langton f367c202d9 refactor(core): remove unused command dependency (#45623) 2026-08-28 13:25:27 -04:00
Kit Langton 9d33d83bb4 refactor(core): reuse native option projection (#45647) 2026-08-28 13:23:33 -04:00
Kit Langton 8f1eff50aa refactor(core): deepen snapshot comparison helper (#45653) 2026-08-28 13:22:33 -04:00
Kit Langton e7918e25fd refactor(core): remove ineffective typed recovery (#45631) 2026-08-28 13:16:13 -04:00
Aiden Cline 1eff84615c fix(ai): keep chat reasoning in one lifecycle (#45864) 2026-08-28 12:14:58 -05:00
Kit Langton f1fd6ba3c0 test(core): await registry readiness barriers (#45630) 2026-08-28 13:14:41 -04:00
Kit Langton 39daec9bec refactor(core): narrow session internals (#45638) 2026-08-28 13:05:16 -04:00
Kit Langton 484f5faf8d docs(plugin): update current API examples (#45627) 2026-08-28 13:04:59 -04:00
Kit Langton 9a227d186f fix(core): remove unreachable MCP authorization path (#45633) 2026-08-28 13:03:17 -04:00
Kit Langton fc27061838 refactor(core): remove tool context aliases (#45639) 2026-08-28 13:03:01 -04:00
Kit Langton d71cc3be77 test(core): guarantee lock fixture cleanup (#45636) 2026-08-28 13:02:50 -04:00
Kit Langton 503e680672 test(core): preserve session layer inference (#45629) 2026-08-28 13:02:21 -04:00
100 changed files with 2765 additions and 1356 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Correct directory page headings when the read offset is zero.
+8 -27
View File
@@ -1000,33 +1000,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
// Reasoning is one response-wide channel: it stays open alongside text and
// refusal output so late reasoning deltas and details join the same block,
// and `finishEvents` closes it once with the complete metadata.
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
@@ -1132,10 +1111,12 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
// Snapshot details at publish time so the emitted event never observes later
// mutation of the accumulated `reasoningDetails` array.
const metadata = reasoningMetadata(
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
+49 -8
View File
@@ -7,6 +7,7 @@ import {
AIError,
LLMEvent,
LLMRequest,
LLMResponse,
Message,
LanguageModel,
ToolCallPart,
@@ -1149,7 +1150,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves scalar reasoning after content starts", () =>
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
@@ -1166,8 +1167,35 @@ describe("OpenAI Chat route", () => {
)
expect(response.reasoning).toBe("detailscalar")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
}),
)
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details },
})
@@ -1213,7 +1241,18 @@ describe("OpenAI Chat route", () => {
index: 0,
},
]
const response = yield* LLMClient.generate(request).pipe(
// Snapshot reasoning-end metadata as each event is published so the
// assertion cannot pass through later mutation of a shared array.
const publishedEndMetadata: unknown[] = []
const response = yield* LLMClient.stream(request).pipe(
Stream.tap((event) =>
Effect.sync(() => {
if (LLMEvent.is.reasoningEnd(event))
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
}),
),
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
Effect.map((state) => LLMResponse.complete(state)!),
Effect.provide(
fixedResponse(
sseEvents(
@@ -1234,10 +1273,12 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
})
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
response.events.findIndex(LLMEvent.is.textStart),
)
// Reasoning stays open alongside text and closes once during finalization.
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
response.events.findIndex(LLMEvent.is.textStart),
)
+3 -2
View File
@@ -80,8 +80,8 @@ export function createSessionRequestModel() {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
return [message.metadata.jobID]
if (message.metadata?.source === "shell")
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
return []
}),
)
@@ -121,6 +121,7 @@ export function createSessionRequestModel() {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
if (typeof shellID === "string" && completed.has(shellID)) return []
const command = part.state.input.command
return [
{
+99 -34
View File
@@ -29,6 +29,7 @@ import type {
SessionMessageAssistantTool,
SessionInfo,
SessionInboxInfo,
SessionInboxCompaction,
ShellInfo,
SkillInfo,
VcsInfo,
@@ -284,12 +285,11 @@ export function createData(config: CreateDataInput) {
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
// Inbox IDs of optimistic prompt admissions still awaiting their durable
// echo. This is the one deliberate piece of in-flight bookkeeping in this
// layer: it exists so a rejection only rolls back rows the server never
// acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
// server does not know about yet. Entries clear on the enqueued echo or on
// rollback — not on POST success, which typically precedes the echo.
// Inbox IDs of optimistic admissions awaiting acknowledgement, so rejection
// only rolls back unacknowledged rows and a pending re-fetch cannot wipe a
// row the server does not know about yet. Prompts clear on their durable
// echo, positive pending read, or rollback; compactions also reconcile the
// POST's canonical ID.
const outbox = new Set<string>()
// Session IDs of optimistic create admissions still awaiting acknowledgement
@@ -303,11 +303,12 @@ export function createData(config: CreateDataInput) {
// to exist server-side instead of failing with "not found".
const creating = new Map<string, Promise<unknown>>()
// Per-session send chain: prompts must be admitted in submission order,
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
// for the previous prompt's POST (settled, so one failure does not block
// the next) before sending its own.
// Per-session send chain: prompts and compactions must be admitted in
// submission order. Each waits for the previous POST to settle, so one
// failure does not block the next.
const sending = new Map<string, Promise<unknown>>()
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
onCleanup(() => compacting.clear())
// Register `promise` under `key` until it settles. A later registration
// replaces an earlier one; settlement only clears its own entry.
@@ -319,9 +320,24 @@ export function createData(config: CreateDataInput) {
void promise.then(settle, settle)
}
// Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
function sendAdmission<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
const created = creating.get(sessionID)
const previous = sending.get(sessionID)
const request = Promise.resolve()
.then(() => Promise.all([gate, created, previous]))
.then(send)
track(
sending,
sessionID,
request.catch(() => undefined),
)
return request
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic prompt admission; the upsert is what reconciles
// handler and by optimistic admission; the upsert is what reconciles
// the durable echo with an optimistic placeholder — the durable payload and
// times replace the client's guess.
function admitLocal(item: SessionInboxInfo) {
@@ -334,6 +350,7 @@ export function createData(config: CreateDataInput) {
item.sessionID,
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
)
if (item.type === "compaction") return
const input = store.session.input[item.sessionID] ?? []
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
materializeInboxMessage(item)
@@ -668,6 +685,7 @@ export function createData(config: CreateDataInput) {
draft.push(existing)
message.reindex(draft, index, position)
})
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
return
}
case "session.inbox.delivery.changed":
@@ -675,6 +693,7 @@ export function createData(config: CreateDataInput) {
return
case "session.inbox.cancelled": {
retractLocal(event.data.sessionID, event.data.inboxID)
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
return
}
case "session.inbox.enqueued": {
@@ -685,6 +704,12 @@ export function createData(config: CreateDataInput) {
timeCreated: event.created,
...event.data.item,
})
if (event.data.item.type === "compaction") {
const active = compacting.get(event.data.sessionID)
active?.observed.add(event.data.inboxID)
if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
removePending(event.data.sessionID, active.id)
}
return
}
case "session.instructions.updated":
@@ -983,6 +1008,7 @@ export function createData(config: CreateDataInput) {
time: { created: event.created },
})
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
case "session.execution.succeeded":
case "session.execution.failed":
@@ -1080,6 +1106,7 @@ export function createData(config: CreateDataInput) {
}
message.append(draft, index, failed)
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
case "permission.asked":
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) return
@@ -1266,12 +1293,17 @@ export function createData(config: CreateDataInput) {
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await api().session.inbox.list({ sessionID })
// A positive read acknowledges admission even when its SSE echo is delayed.
pending.forEach((item) => outbox.delete(item.id))
// Compactions also coalesce by Session, not just by the proposed ID.
if (pending.some((item) => item.type === "compaction"))
store.session.pending[sessionID]
?.filter((item) => item.type === "compaction")
.forEach((item) => outbox.delete(item.id))
// Keep optimistic rows still awaiting their echo: this fetch may
// have raced ahead of an in-flight admission the server does not
// know about yet.
const inflight = (store.session.pending[sessionID] ?? []).filter(
(item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id),
)
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
batch(() => {
setStore("session", "pending", sessionID, reconcile(merged))
@@ -1345,13 +1377,56 @@ export function createData(config: CreateDataInput) {
if (fresh) track(creating, id, request)
return { id, request }
},
compact(input: { sessionID: string; model?: ModelRef }) {
const active = compacting.get(input.sessionID)
if (active) return active.request
// A known pending control ID may be consumed while setup waits. Propose
// a fresh ID and let the server coalesce, without duplicating its row.
const id = SessionMessage.ID.create()
if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
outbox.add(id)
admitLocal({
id,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "compaction",
delivery: "steer",
payload: {},
})
}
// Compaction admission can coalesce onto a different ID. Retire the
// speculative row on an echo, and remember consumed IDs until the POST
// settles so its older response cannot resurrect a queued row.
const observed = new Set<string>()
const request = sendAdmission(input.sessionID, async () => {
if (input.model) await api().session.switchModel({ sessionID: input.sessionID, model: input.model })
return api().session.compact({ sessionID: input.sessionID, id })
})
.then((item) => {
batch(() => {
outbox.delete(id)
if (item.id !== id) removePending(input.sessionID, id)
if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id)) admitLocal(item)
})
return item
})
.catch((error) => {
if (outbox.delete(id)) removePending(input.sessionID, id)
throw error
})
.finally(() => {
if (compacting.get(input.sessionID)?.request === request) compacting.delete(input.sessionID)
})
compacting.set(input.sessionID, { id, observed, request })
return request
},
// Optimistic prompt admission: render the prompt immediately under a
// client-minted ID, send it, and let the durable inbox.enqueued echo
// upsert that same ID with the server's payload. Server admission is
// idempotent per ID, so retrying with the identical payload cannot
// double-admit.
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
const { gate, ...request } = input
prompt(input: SessionPromptInput & { gate?: Promise<unknown>; prepare?: () => Promise<unknown> }) {
const { gate, prepare, ...request } = input
const id = request.id ?? SessionMessage.ID.create()
// A retry may reuse an ID that is already rendered — and possibly
// already durable. Admit optimistically only for new IDs so a failed
@@ -1377,25 +1452,15 @@ export function createData(config: CreateDataInput) {
},
})
}
// Wrapped so even a synchronous client failure reaches the rollback.
// The POST additionally waits for the caller's gate, for any
// in-flight optimistic create of this session, and for the previous
// prompt's POST: the row renders now, the send happens once the
// session exists server-side and earlier prompts are admitted.
const previous = sending.get(request.sessionID)
const send = Promise.resolve()
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
.then(() => api().session.prompt({ ...request, id }))
track(
sending,
return sendAdmission(
request.sessionID,
send.then(
() => undefined,
() => undefined,
),
)
return send.catch((error) => {
// Roll back only rows this call admitted and the echo has not
async () => {
await prepare?.()
return api().session.prompt({ ...request, id })
},
gate,
).catch((error) => {
// Roll back only rows this call admitted and the server has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
throw error
@@ -0,0 +1,400 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise"
test("admits compaction before model setup and serializes the following prompt", async () => {
using fixture = setup()
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const proposed = fixture.data.session.pending.list(sessionID)[0]
expect(proposed).toMatchObject({ type: "compaction", sessionID })
expect(fixture.calls).toEqual([])
expect(fixture.data.session.message.list(sessionID)).toEqual([])
expect(fixture.data.session.status(sessionID)).toBe("idle")
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
await wait(() => fixture.calls.length === 1)
expect(fixture.calls).toEqual(["model"])
fixture.model.resolve()
await wait(() => fixture.calls.length === 2)
expect(fixture.calls).toEqual(["model", "compact"])
fixture.response.resolve(Response.json({ data: item(proposed.id) }))
await Promise.all([compact, prompt])
expect(fixture.calls).toEqual(["model", "compact", "prompt"])
expect(fixture.proposals).toEqual([proposed.id])
})
test("coalesces duplicate gestures until the admission request settles", async () => {
using fixture = setup()
const first = fixture.data.session.compact({ sessionID })
expect(fixture.data.session.compact({ sessionID })).toBe(first)
expect(fixture.data.session.pending.list(sessionID)).toHaveLength(1)
await wait(() => fixture.calls.length === 1)
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await first
expect(fixture.calls).toEqual(["compact"])
const next = fixture.data.session.compact({ sessionID })
expect(next).not.toBe(first)
await next
expect(fixture.calls).toEqual(["compact", "compact"])
expect(new Set(fixture.proposals).size).toBe(2)
expect(fixture.proposals).not.toContain("msg_canonical")
})
test("substitutes the canonical response ID and reconciles its later echo", async () => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID })
const proposed = fixture.data.session.pending.list(sessionID)[0].id
await fixture.data.session.pending.sync(sessionID)
expect(fixture.data.session.pending.list(sessionID).map((row) => row.id)).toEqual([proposed])
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await request
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical")])
fixture.enqueue("msg_canonical", 20)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical", 20)])
expect(fixture.data.session.input.list(sessionID)).toEqual([])
})
test.each(["proposed", "canonical"])("adopts the %s echo before the response without duplicating it", async (kind) => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID })
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
fixture.enqueue(id, 20)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
fixture.response.resolve(Response.json({ data: item(id) }))
await request
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
})
test.each(["started", "cancelled", "failed"])(
"does not resurrect a canonical item already %s before the response",
async (kind) => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID })
fixture.enqueue("msg_canonical")
if (kind === "started")
fixture.emit({
...event,
type: "session.compaction.started",
data: { sessionID, inputID: "msg_canonical", reason: "manual" },
})
if (kind === "cancelled")
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: "msg_canonical" } })
if (kind === "failed")
fixture.emit({
...event,
type: "session.compaction.failed",
data: {
sessionID,
inputID: "msg_canonical",
reason: "manual",
error: { type: "aborted", message: "Cancelled" },
},
})
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await request
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
if (kind === "started") {
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary" },
])
}
},
)
test.each(["model", "compact"])("rolls back a rejected %s RPC and releases the following prompt", async (rpc) => {
using fixture = setup()
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const failed = request.catch((error: unknown) => error)
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
if (rpc === "model") fixture.model.reject(new Error("Model setup failed"))
if (rpc === "compact") {
fixture.model.resolve()
fixture.response.resolve(new Response("Admission failed", { status: 500 }))
}
expect(await failed).toBeInstanceOf(Error)
await prompt
expect(fixture.data.session.pending.list(sessionID).map((row) => row.type)).toEqual(["user"])
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
})
test.each(["proposed", "canonical", "existing"])(
"preserves acknowledged %s compaction after an HTTP error",
async (kind) => {
using fixture = setup()
if (kind === "existing") fixture.enqueue("msg_canonical")
const request = fixture.data.session.compact({ sessionID })
const failed = request.catch((error: unknown) => error)
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
if (kind !== "existing") fixture.enqueue(id)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
fixture.response.resolve(new Response("Lost response", { status: 500 }))
expect(await failed).toBeInstanceOf(Error)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
expect(fixture.listeners.size).toBe(1)
},
)
test("uses a fresh control ID when the known pending compaction starts during model setup", async () => {
const proposed = Promise.withResolvers<string>()
using fixture = setup(async (request) => {
if (!request.url.endsWith("/compact")) return undefined
const body = await request.json()
proposed.resolve(body.id)
if (body.id === "msg_existing") return Response.json({ message: "Control ID already consumed" }, { status: 409 })
return Response.json({ data: item(body.id) })
})
fixture.enqueue("msg_existing")
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const result = request.catch((error: unknown) => error)
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_existing")])
await wait(() => fixture.calls.includes("model"))
fixture.emit({
...event,
type: "session.compaction.started",
data: { sessionID, inputID: "msg_existing", reason: "manual" },
})
fixture.model.resolve()
expect(await proposed.promise).not.toBe("msg_existing")
expect(await result).toEqual(item(await proposed.promise))
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(await proposed.promise)])
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ id: "msg_existing", type: "compaction", status: "running" },
])
})
test.each(["compaction", "canonical compaction", "user"])(
"preserves a fetched durable %s when SSE is delayed and HTTP fails",
async (type) => {
using fixture = setup(async (request) => {
if (request.url.endsWith("/prompt")) return fixture.response.promise
return undefined
})
const request =
type === "user"
? fixture.data.session.prompt({ sessionID, text: "Follow up" })
: fixture.data.session.compact({ sessionID })
const result = request.catch((error: unknown) => error)
const id = type === "canonical compaction" ? "msg_canonical" : fixture.data.session.pending.list(sessionID)[0].id
const durable: SessionInboxInfo =
type === "user" ? { ...item(id, 20), type: "user", payload: { text: "Follow up" } } : item(id, 20)
fixture.pending.push(durable)
await fixture.data.session.pending.sync(sessionID)
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
fixture.response.resolve(new Response("Lost response", { status: 500 }))
expect(await result).toBeInstanceOf(Error)
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
if (type === "user")
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ id, type: "user", text: "Follow up" }])
},
)
test("keeps one event listener and removes it when the data owner is disposed during a gate", async () => {
using fixture = setup()
const gate = Promise.withResolvers<void>()
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
const compact = fixture.data.session.compact({ sessionID })
expect(fixture.listeners.size).toBe(1)
fixture.dispose()
expect(fixture.listeners.size).toBe(0)
gate.resolve()
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await Promise.all([first, compact])
expect(fixture.listeners.size).toBe(0)
})
test("routes concurrent compaction observations by session through one listener", async () => {
const firstResponse = Promise.withResolvers<Response>()
const secondResponse = Promise.withResolvers<Response>()
using fixture = setup(async (request) => {
if (!request.url.endsWith("/compact")) return undefined
return request.url.includes(`/session/${sessionID}/`) ? firstResponse.promise : secondResponse.promise
})
const first = fixture.data.session.compact({ sessionID })
const second = fixture.data.session.compact({ sessionID: "ses_other" })
const firstID = fixture.data.session.pending.list(sessionID)[0].id
const secondID = fixture.data.session.pending.list("ses_other")[0].id
expect(fixture.listeners.size).toBe(1)
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: firstID } })
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
expect(fixture.data.session.pending.list("ses_other").map((row) => row.id)).toEqual([secondID])
firstResponse.resolve(Response.json({ data: item(firstID) }))
secondResponse.resolve(Response.json({ data: { ...item(secondID), sessionID: "ses_other" } }))
await Promise.all([first, second])
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
expect(fixture.data.session.pending.list("ses_other")).toEqual([{ ...item(secondID), sessionID: "ses_other" }])
expect(fixture.listeners.size).toBe(1)
})
test.each(["gate", "prepare"])(
"a preceding prompt's failed %s does not block compaction or following model preparation",
async (kind) => {
using fixture = setup()
const gate = Promise.withResolvers<void>()
const prepared: string[] = []
const first = fixture.data.session
.prompt({
sessionID,
id: "msg_first",
text: "First",
gate: kind === "gate" ? gate.promise : undefined,
prepare: () => {
prepared.push("first")
return gate.promise
},
})
.catch((error: unknown) => error)
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "first" } })
const following = fixture.data.session.prompt({
sessionID,
text: "Follow up",
prepare: () => {
prepared.push("following")
return fixture.api.session.switchModel({ sessionID, model: { providerID: "demo", id: "second" } })
},
})
if (kind === "prepare") await wait(() => prepared.includes("first"))
gate.reject(new Error("Preparation failed"))
expect(await first).toBeInstanceOf(Error)
await wait(() => fixture.calls.includes("model"))
expect(prepared).toEqual(kind === "prepare" ? ["first"] : [])
fixture.model.resolve()
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
await Promise.all([compact, following])
expect(fixture.calls).toEqual(["model", "compact", "model", "prompt"])
expect(prepared.at(-1)).toBe("following")
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
},
)
test("creation failure rejects gated prompt, compaction, and following preparation without sending their RPCs", async () => {
const creation = Promise.withResolvers<Response>()
const requested = Promise.withResolvers<void>()
using fixture = setup(async (request) => {
if (!request.url.endsWith("/api/session")) return undefined
requested.resolve()
return creation.promise
})
const gate = Promise.withResolvers<void>()
const prepared: string[] = []
const created = fixture.data.session.create({ id: sessionID })
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
const following = fixture.data.session.prompt({
sessionID,
text: "Follow up",
prepare: async () => {
prepared.push("following")
},
})
const results = Promise.allSettled([created.request, first, compact, following])
await requested.promise
creation.resolve(new Response("Creation failed", { status: 500 }))
expect((await results).map((result) => result.status)).toEqual(["rejected", "rejected", "rejected", "rejected"])
expect(fixture.calls).toEqual([])
expect(prepared).toEqual([])
expect(fixture.data.session.get(sessionID)).toBeUndefined()
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
expect(fixture.listeners.size).toBe(1)
gate.resolve()
})
const sessionID = "ses_compact"
const event = { id: "evt_compact", created: 10, durable: { aggregateID: sessionID, seq: 1, version: 1 } }
const item = (id: string, timeCreated = 10): SessionInboxCompaction => ({
id,
sessionID,
timeCreated,
type: "compaction",
delivery: "steer",
payload: {},
})
function setup(override?: (request: Request) => Promise<Response | undefined>) {
const model = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const calls: string[] = []
const proposals: string[] = []
const pending: SessionInboxInfo[] = []
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const overridden = await override?.(request)
if (overridden) return overridden
const rpc = new URL(request.url).pathname.split("/").at(-1)
if (rpc === "inbox") return Response.json({ data: pending })
if (rpc === "model") {
calls.push(rpc)
await model.promise
return new Response(null, { status: 204 })
}
if (rpc === "compact") {
calls.push(rpc)
proposals.push((await request.json()).id)
return (await response.promise).clone()
}
if (rpc === "prompt") {
calls.push(rpc)
return Response.json({
data: { ...item((await request.json()).id), type: "user", payload: { text: "Follow up" } },
})
}
throw new Error(`Unexpected request: ${request.url}`)
},
})
const root = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
return {
data: root.data,
api,
dispose: root.dispose,
[Symbol.dispose]: root.dispose,
model,
response,
calls,
proposals,
pending,
listeners,
emit,
enqueue(id: string, created = 10) {
emit({
...event,
created,
type: "session.inbox.enqueued",
data: { sessionID, inboxID: id, item: { type: "compaction", delivery: "steer", payload: {} } },
})
},
}
}
async function wait(predicate: () => boolean) {
for (let attempt = 0; attempt < 100; attempt++) {
if (predicate()) return
await Bun.sleep(5)
}
throw new Error("Timed out waiting for request")
}
+17 -28
View File
@@ -84,10 +84,7 @@ export function map(input: MapInput): Mapping | undefined {
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(
input.settings,
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
),
...mapGoogleOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
@@ -296,7 +293,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
@@ -311,7 +308,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
...extra,
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
@@ -345,28 +341,21 @@ function mapOpenRouter(
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
const options = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
![
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"promptCacheKey",
"timeout",
].includes(key),
),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
return mapProviderOptions(settings, [
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"promptCacheKey",
"timeout",
])
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
-107
View File
@@ -1,107 +0,0 @@
export * as CommandInvocation from "./invocation.js"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/schema/agent"
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { Command } from "../command.js"
import { Location } from "../location.js"
import { ShellSelect } from "../shell/select.js"
// Invocation for configured template commands; source loading and registration stay with the caller.
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
delivery: input.delivery,
})
})
})
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+104 -3
View File
@@ -1,12 +1,18 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandInvocation } from "../../command/invocation.js"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -23,7 +29,9 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const invoke = yield* CommandInvocation.make(ctx)
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -55,7 +63,37 @@ export const Plugin = define({
draft.add({
name,
description: command.description,
execute: (input) => invoke(command, input),
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
})
}
}
@@ -108,3 +146,66 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+1 -1
View File
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
find: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
return Service.of({
find: (input) =>
Effect.sync(() => {
+3 -4
View File
@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
}
@@ -346,8 +346,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.backgrounded)
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
if (result.info && result.backgrounded) yield* Deferred.succeed(result.backgrounded, result.info)
return result.info
})
@@ -396,7 +395,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
return result.info
})
+3 -18
View File
@@ -10,17 +10,14 @@ import {
CallToolResultSchema,
ElicitationCompleteNotificationSchema,
ElicitRequestSchema,
GetPromptResultSchema,
type Implementation,
type ElicitRequestFormParams,
type ElicitRequestParams,
type ElicitRequestURLParams,
type ElicitResult,
ListPromptsResultSchema,
ListRootsRequestSchema,
ListToolsResultSchema,
PromptListChangedNotificationSchema,
PromptSchema,
ResourceListChangedNotificationSchema,
type LoggingMessageNotification,
LoggingMessageNotificationSchema,
@@ -41,10 +38,6 @@ const toError = (error: unknown) => (error instanceof Error ? error : new Error(
const TolerantListToolsResult = ListToolsResultSchema.extend({
tools: ToolSchema.omit({ outputSchema: true }).array(),
})
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
prompts: PromptSchema.array(),
})
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
server: Schema.String,
}) {
@@ -301,12 +294,8 @@ export const connect = Effect.fnUntraced(function* (
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: catalogTimeout,
})
},
(cursor) =>
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.prompts,
),
catch: toError,
@@ -396,11 +385,7 @@ export const connect = Effect.fnUntraced(function* (
prompt: (input) =>
Effect.tryPromise({
try: (signal) =>
client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema,
{ signal, timeout: executionTimeout },
),
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
catch: toError,
}).pipe(
Effect.map((result) => ({
+14 -15
View File
@@ -473,11 +473,11 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
entry.status = { status: "failed", error: "Connection closed" }
yield* stopServer(name, entry)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).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 +512,7 @@ export const layer = (options?: Options) =>
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).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 +543,9 @@ export const layer = (options?: Options) =>
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
// after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model.
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).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 +557,7 @@ export const layer = (options?: Options) =>
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).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 +568,9 @@ export const layer = (options?: Options) =>
entry.tools = undefined
entry.prompts = undefined
yield* Scope.close(scope, Exit.void)
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).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 +592,7 @@ export const layer = (options?: Options) =>
yield* register(name, entry)
if (serverConfig.disabled) {
entry.status = { status: "disabled" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
return
}
yield* startServer(name, entry)
@@ -608,7 +608,7 @@ export const layer = (options?: Options) =>
yield* disposeServer(name, entry)
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
entries.delete(name)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
})
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
@@ -631,7 +631,7 @@ export const layer = (options?: Options) =>
if (entry.config.disabled) {
entry.status = { status: "disabled" }
entry.startup.openUnsafe()
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
continue
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
@@ -673,7 +673,6 @@ export const layer = (options?: Options) =>
bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
),
)
const state = State.create<Data, Draft>({
@@ -738,7 +737,7 @@ export const layer = (options?: Options) =>
const target = yield* requireServer(name)
yield* stopServer(name, target.entry)
target.entry.status = { status: "disabled" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}).pipe(locks.withLock(name))
}),
remove: Effect.fn("MCP.remove")(function* (server) {
+1 -10
View File
@@ -213,20 +213,11 @@ export const authorize = (input: {
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
const result = yield* Effect.tryPromise({
yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
if (result === "AUTHORIZED") {
return {
url: input.config.url,
instructions: `Connected to ${input.name}.`,
mode: "auto" as const,
callback: finalize,
}
}
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
+2 -2
View File
@@ -111,7 +111,7 @@ const layer = Layer.effect(
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
if (previous) yield* Scope.close(previous.scope, Exit.void)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
@@ -142,7 +142,7 @@ const layer = Layer.effect(
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
discard: true,
})
inventory = [...nextInventory, ...failures]
+1 -1
View File
@@ -98,7 +98,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
list: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
return response(agents.list())
},
reload: agents.reload,
transform: (callback) =>
@@ -4,7 +4,6 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Provider } from "../../provider.js"
type MantleSDK = {
languageModel: (modelID: string) => LanguageModelV3
chat: (modelID: string) => LanguageModelV3
responses: (modelID: string) => LanguageModelV3
}
@@ -58,10 +58,8 @@ export const CloudflareAIGatewayPlugin = define({
const config = gatewayConfig(evt.options)
if (!config) return
const metadata = gatewayMetadata(evt.options)
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie)
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
Effect.orDie,
)
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
const gateway = createAiGateway({
accountId: config.accountId,
gateway: config.gatewayId,
+2 -14
View File
@@ -1,8 +1,7 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { loadSDKFactory } from "./sdk-factory.js"
export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
@@ -13,18 +12,7 @@ export const DynamicProviderPlugin = define({
Effect.fn(function* (evt) {
if (evt.sdk) return
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
evt.sdk = mod[match](evt.options)
evt.sdk = ((yield* loadSDKFactory(npm, evt.package)) as (options: any) => any)(evt.options)
}),
)
}),
+1 -1
View File
@@ -40,7 +40,7 @@ export const GitLabPlugin = define({
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
const id = evt.model.modelID ?? evt.model.id
if (id.startsWith("duo-workflow-")) {
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider"))
const workflowRef =
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
const workflowDefinition =
@@ -1,9 +1,8 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
import { loadSDKFactory } from "./sdk-factory.js"
export const SapAICorePlugin = define({
id: "opencode.provider.sap.ai.core",
@@ -18,17 +17,7 @@ export const SapAICorePlugin = define({
(typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined)
if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
const factory = mod[match]
const factory = yield* loadSDKFactory(npm, evt.package)
if (typeof factory !== "function")
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
@@ -0,0 +1,18 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
const installedPath = packageName.startsWith("file://")
? packageName
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
return mod[match]
})
+1 -1
View File
@@ -50,7 +50,7 @@ export const Plugin = define({
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
app: Context["app"],
) {
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
const plugins = yield* configuredPlugins()
return [
ReportContent,
"",
+3 -3
View File
@@ -5,8 +5,8 @@ import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "eff
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
import { Workspace } from "./workspace.js"
import { Model } from "./model.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable, SessionTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { Agent } from "./agent.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
@@ -204,7 +204,6 @@ export const layer = (options?: Options) =>
yield* jobs.background(background.id)
yield* jobs.wait({ id: background.id }).pipe(
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
Effect.ignore,
Effect.forkIn(scope),
)
})
+7 -7
View File
@@ -1,13 +1,13 @@
import { DateTime, Schema } from "effect"
import { Agent } from "../agent.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { Project } from "../project.js"
import { Provider } from "../provider.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { AbsolutePath, RelativePath } from "../schema.js"
import { Workspace } from "../workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { SessionSchema } from "./schema.js"
import { SessionTable } from "./sql.js"
import type { SessionTable } from "./sql.js"
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
import { Money } from "@opencode-ai/schema/money"
+17 -23
View File
@@ -4,21 +4,17 @@ import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getShell: (
shellID: SessionMessage.Shell["shellID"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined>
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"]>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getShell: (shellID: SessionMessage.Shell["shellID"]) => Effect.Effect<SessionMessage.Shell | undefined>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void>
}
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
@@ -38,16 +34,14 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const created = DateTime.makeUnsafe(event.created)
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
)
const latestTool = (assistant: DraftAssistant, id: string) =>
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestText = (assistant: DraftAssistant) =>
assistant.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const latestReasoning = (assistant: DraftAssistant) =>
assistant.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
+15 -13
View File
@@ -6,13 +6,13 @@ import path from "path"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent.js"
import { Model } from "../model.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js"
import { Workspace } from "../workspace.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { InstructionEntry } from "./instruction-entry.js"
@@ -26,8 +26,10 @@ import type { SessionSchema } from "./schema.js"
import { ProjectTable } from "../project/sql.js"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
@@ -53,16 +55,16 @@ const forkTitle = (value?: string) => {
return `${value} (fork #1)`
}
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
return db
.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
cost: sql`${SessionTable.cost} + ${value.cost}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
@@ -73,7 +75,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select({
+1 -1
View File
@@ -143,7 +143,7 @@ const layer = Layer.effect(
entering && !continuing ? promotable : "steer",
)
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
})
if (promoted > 0) step = 1
+6 -1
View File
@@ -221,10 +221,15 @@ export const make = Effect.gen(function* () {
})
}
// After durable output, recovery continues instead of replaying: the
// partial assistant message is already persisted history. Any failure
// the pre-output gate would retry is continued here, plus interrupted
// streams, whose read failures may carry delivery states the retry
// policy rejects for full resends.
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
+3 -3
View File
@@ -5,10 +5,10 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { PermissionV1 } from "../v1/permission.js"
import { Project } from "../project.js"
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import type { Project } from "@opencode-ai/schema/project"
import type { SessionSchema } from "./schema.js"
import { Workspace } from "../workspace.js"
import type { Workspace } from "@opencode-ai/schema/workspace"
import { Timestamps } from "../database/schema.sql.js"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
+6 -7
View File
@@ -255,7 +255,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
Effect.tap((rows) =>
Effect.sync(() => {
rows.forEach((row) => {
addToolStatus(toolTotals, row.status, 1)
addToolStatus(toolTotals, row.status)
if (!row.name) return
const tool = tools.get(row.name) ?? {
name: row.name,
@@ -266,7 +266,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
durations: [],
}
tools.set(row.name, tool)
addToolStatus(tool, row.status, 1)
addToolStatus(tool, row.status)
if (row.duration !== null) tool.durations.push(row.duration)
})
}),
@@ -385,18 +385,17 @@ function tokenTotal(tokens: Tokens) {
function addToolStatus(
target: { calls: number; succeeded: number; failed: number; unfinished: number },
status: string | null,
count: number,
) {
target.calls += count
target.calls++
if (status === "completed") {
target.succeeded += count
target.succeeded++
return
}
if (status === "error") {
target.failed += count
target.failed++
return
}
target.unfinished += count
target.unfinished++
}
function makeDateKey(timezone = "UTC") {
+1 -1
View File
@@ -3,7 +3,7 @@ export * as SessionUsage from "./usage.js"
import type { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { Model } from "../model.js"
import type { Model } from "@opencode-ai/schema/model"
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
+1 -2
View File
@@ -305,7 +305,7 @@ const layer = () =>
}),
)
yield* outputDone.open
}).pipe(Effect.catch(() => outputDone.open)),
}),
)
yield* Effect.promise(
() =>
@@ -356,7 +356,6 @@ const layer = () =>
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
+2 -4
View File
@@ -7,6 +7,7 @@ import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select.js"
import { lazy } from "../util/lazy.js"
import { Wildcard } from "../util/wildcard.js"
type Part = { type: string; text: string }
@@ -355,10 +356,7 @@ function resolve(asset: string) {
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
const load = lazy(initialize)
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
+14 -16
View File
@@ -133,36 +133,34 @@ const layer = Layer.effect(
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
source: repo.source,
input: {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
},
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause)))
const ignored = yield* git.index
.ignored({ repository: comparison.source, paths: files })
.pipe(Effect.mapError((cause) => failure("files", cause)))
return files.filter((file) => !ignored.has(file))
return comparison.files.filter((file) => !comparison.ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause)))
const ignored = yield* git.index
.ignored({ repository: comparison.source, paths: files })
.pipe(Effect.mapError((cause) => failure("diff", cause)))
return yield* git.tree
.diff({
...comparison.input,
context: input.context,
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
})
+2 -2
View File
@@ -6,7 +6,7 @@
*/
export * as EditTool from "./edit.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
@@ -108,7 +108,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
+2 -2
View File
@@ -1,7 +1,7 @@
export * as GlobTool from "./glob.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment/index.js"
@@ -41,7 +41,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
/** Glob leaf that defaults its filesystem root to the active Location. */
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as GrepTool from "./grep.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
@@ -57,7 +57,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
/** Grep leaf that defaults its filesystem root to the active Location. */
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as PatchTool from "./patch.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Result, Schema } from "effect"
@@ -65,7 +65,7 @@ type Prepared =
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as QuestionTool from "./question.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../../form.js"
@@ -47,7 +47,7 @@ export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answer
export const Plugin = {
id: "opencode.tool.question",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
const forms = yield* Form.Service
const permission = yield* Permission.Service
+3 -3
View File
@@ -1,6 +1,6 @@
export * as ReadTool from "./read.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { basename, dirname, join } from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
@@ -29,7 +29,7 @@ const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.
export const Plugin = {
id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
@@ -169,7 +169,7 @@ export const toModelContent = (path: string, offset: number | undefined, output:
] as const
if (output.type === "list-page") {
const start = offset ?? 1
const start = offset || 1
const content = [
output.entries.length === 0
? `Read directory ${path}, 0 entries`
+6 -5
View File
@@ -1,7 +1,7 @@
export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
@@ -102,7 +102,7 @@ const backgroundResult = (shellID: string, file: string) => ({
export const Plugin = {
id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
@@ -280,7 +280,8 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
// CodeMode children share a tool-call ID, but each shell must own its job.
id: info.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@@ -295,7 +296,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
@@ -304,7 +305,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")
+2 -2
View File
@@ -1,6 +1,6 @@
export * as SkillTool from "./skill.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -31,7 +31,7 @@ const unableToLoad = (name: string, error?: unknown) =>
export const Plugin = {
id: "opencode.tool.skill",
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
const fs = yield* FSUtil.Service
const skills = yield* Skill.Service
const permission = yield* Permission.Service
+2 -2
View File
@@ -1,7 +1,7 @@
export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
@@ -52,7 +52,7 @@ export const description = [
export const Plugin = {
id: "opencode.tool.subagent",
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
const runtime = yield* PluginRuntime.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -102,7 +102,7 @@ const convert = (content: string, contentType: string, format: Format) => {
export const Plugin = {
id: "opencode.tool.webfetch",
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
const http = yield* HttpClient.HttpClient
const permission = yield* Permission.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
@@ -26,7 +26,7 @@ const Output = Schema.Struct({
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: Context) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const websearch = yield* WebSearch.Service
+2 -2
View File
@@ -6,7 +6,7 @@
*/
export * as WriteTool from "./write.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
@@ -45,7 +45,7 @@ export const toModelContent = (output: Output) =>
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
@@ -1,215 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer } from "effect"
import { CommandInvocation } from "@opencode-ai/core/command/invocation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Agent } from "@opencode-ai/schema/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { AppProcess } from "@opencode-ai/util/process"
import { tempLocationLayer } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const shell = ShellSelect.Service.of({
resolve: (input) =>
Effect.sync(() => {
expect(input).toEqual({ priority: "config" })
return "sh"
}),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
})
const it = testEffect(
Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), tempLocationLayer, Layer.succeed(ShellSelect.Service, shell)),
)
const sessionID = Session.ID.make("ses_command_invocation")
describe("CommandInvocation", () => {
it.effect("expands arguments without changing unconfigured session defaults or prompt attachments", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const invoke = yield* CommandInvocation.make(promptHost(prompts))
const files = [{ uri: "file:///context.md", name: "context" }]
for (const [template, text, expected] of [
[
"$2 / $1 / $2",
`"alpha beta" 'gamma delta' [Image 3] tail`,
"gamma delta [Image 3] tail / alpha beta / gamma delta [Image 3] tail",
],
["[$1][$3]", "one two", "[one][]"],
["raw [$ARGUMENTS]", `"alpha beta" 'gamma delta'`, `raw ["alpha beta" 'gamma delta']`],
[" Review ", " details ", "Review \n\n details"],
[" Review ", " ", "Review"],
]) {
expect(
yield* invoke(new ConfigCommand.Info({ template }), {
sessionID,
prompt: { text, files },
delivery: "queue",
}),
).toBeUndefined()
expect(prompts.at(-1)).toEqual({ sessionID, text: expected, files, delivery: "queue" })
}
}),
)
it.effect("switches agents before applying command or agent model defaults and admitting the prompt", () =>
Effect.gen(function* () {
const calls: unknown[] = []
const ctx = promptHost(calls)
const location = yield* Location.Service
const reviewer = Agent.ID.make("reviewer")
const agentModel = { id: Model.ID.make("agent-model"), providerID: Provider.ID.make("example") }
const commandModel = {
model: Model.ID.make("command-model"),
providerID: Provider.ID.make("example"),
variant: Model.VariantID.make("careful"),
}
const session = Session.Info.make({
id: sessionID,
projectID: location.project.id,
agent: Agent.ID.make("build"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: location.directory },
})
for (const testCase of [
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer, model: commandModel }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: { id: "command-model", providerID: "example", variant: "careful" } }],
],
},
{
currentAgent: reviewer,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: agentModel }],
],
},
{
currentAgent: session.agent,
agentModel: undefined,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
],
},
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({
template: "Review",
model: { model: commandModel.model, providerID: commandModel.providerID },
}),
expected: [["switchModel", { sessionID, model: { id: "command-model", providerID: "example" } }]],
},
]) {
calls.length = 0
const invoke = yield* CommandInvocation.make(
host({
agent: {
...ctx.agent,
get: (input) =>
Effect.sync(() => {
calls.push(["agent.get", input])
return { location, data: { ...Agent.Info.default(reviewer), model: testCase.agentModel } }
}),
},
session: {
...ctx.session,
get: (input) =>
Effect.sync(() => {
calls.push(["session.get", input])
return { ...session, agent: testCase.currentAgent }
}),
switchAgent: (input) => Effect.sync(() => calls.push(["switchAgent", input])),
switchModel: (input) => Effect.sync(() => calls.push(["switchModel", input])),
},
}),
)
yield* invoke(testCase.command, {
sessionID,
prompt: { text: "" },
delivery: "steer",
})
expect(calls).toEqual([...testCase.expected, { sessionID, text: "Review", delivery: "steer" }])
}
}),
)
it.live("interpolates in source order using the location, closed stdin and nonzero-exit output", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
yield* Effect.promise(() => Bun.write(path.join(location.directory, "context.txt"), "context"))
const invoke = yield* CommandInvocation.make(promptHost(prompts))
yield* invoke(
new ConfigCommand.Info({
template:
'first=!`read value || printf closed-; cat context.txt; sleep 0.05; printf "%s" "-stderr" >&2; exit 7`; second=!`printf "%s" "$1"`',
}),
{ sessionID, prompt: { text: "argument" }, delivery: "steer" },
)
expect(prompts).toEqual([{ sessionID, text: "first=closed-context-stderr; second=argument", delivery: "steer" }])
}),
)
it.live("wraps process failures with the shell source and does not admit a prompt", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
const missing = path.join(location.directory, "missing-shell")
const invoke = yield* CommandInvocation.make(promptHost(prompts)).pipe(
Effect.provideService(ShellSelect.Service, { ...shell, resolve: () => Effect.succeed(missing) }),
)
const error = yield* invoke(new ConfigCommand.Info({ template: '!`printf "hello"`' }), {
sessionID,
prompt: { text: "" },
delivery: "steer",
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(Error)
expect(String(error)).toContain('Shell interpolation failed for "printf \\"hello\\"": Command failed:')
expect(String(error)).toContain(missing)
expect(prompts).toEqual([])
}),
)
})
function promptHost(prompts: unknown[]) {
return host({
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push(input)
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_command_invocation"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
})
}
+2 -22
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
@@ -12,6 +12,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -28,27 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
+22
View File
@@ -0,0 +1,22 @@
import { Effect } from "effect"
export function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
Object.entries(variables).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
+27
View File
@@ -1,9 +1,12 @@
import { $ } from "bun"
import { execFile } from "child_process"
import fs from "fs/promises"
import path from "path"
import { promisify } from "util"
import { pathToFileURL } from "url"
import { Repository } from "@opencode-ai/core/repository"
import { Effect } from "effect"
import { tmpdir } from "./tmpdir"
const exec = promisify(execFile)
@@ -29,6 +32,30 @@ export async function gitRemote(root: string) {
}
}
export function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
export function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
export async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
export async function commit(source: string, content: string, message: string) {
await fs.writeFile(path.join(source, "README.md"), content)
await git(source, "add", "README.md")
+53
View File
@@ -0,0 +1,53 @@
import { spawn } from "child_process"
import path from "path"
const root = path.join(import.meta.dir, "../..")
export function runLockWorker(entrypoint: string, payload: unknown) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [entrypoint, JSON.stringify(payload)], { cwd: root })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
})
})
}
export function spawnLockWorker(entrypoint: string, payload: unknown) {
return spawn(process.execPath, [entrypoint, JSON.stringify(payload)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
export async function stopLockWorker(proc: ReturnType<typeof spawnLockWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
await closed
return
}
await new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
await closed
}
export async function waitForFile(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await Bun.file(file).exists()) return
await Bun.sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
+30
View File
@@ -0,0 +1,30 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? { prompts: [{ name: "second", description: "Second prompt" }] }
: {
prompts: [
{
name: "first",
description: "First prompt",
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
},
],
nextCursor: "page-2",
},
),
)
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
Promise.resolve({
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
}),
)
await server.connect(new StdioServerTransport())
+2 -28
View File
@@ -6,7 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, gitRemote } from "./fixture/git"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -75,30 +75,6 @@ describe("Git", () => {
)
})
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
describe("Git worktrees", () => {
it.live("creates, lists, and removes linked worktrees", () =>
Effect.gen(function* () {
@@ -109,9 +85,7 @@ describe("Git worktrees", () => {
yield* Effect.promise(() => initRepo(root.path))
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })))
const git = yield* Git.Service
const repo = yield* git.repo.discover(directory)
if (!repo) throw new Error("Repository not found")
@@ -1,9 +1,9 @@
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
import { convertToOpenAICompatibleChatMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
import { describe, test, expect } from "bun:test"
describe("system messages", () => {
test("should convert system message content to string", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "system",
content: "You are a helpful assistant with AGENTS.md instructions.",
@@ -21,7 +21,7 @@ describe("system messages", () => {
describe("user messages", () => {
test("should convert messages with only a text part to a string content", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [{ type: "text", text: "Hello" }],
@@ -32,7 +32,7 @@ describe("user messages", () => {
})
test("should convert messages with image parts", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -61,7 +61,7 @@ describe("user messages", () => {
})
test("should convert messages with image parts from Uint8Array", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -90,7 +90,7 @@ describe("user messages", () => {
})
test("should handle URL-based images", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -117,7 +117,7 @@ describe("user messages", () => {
})
test("should handle multiple text parts without flattening", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "user",
content: [
@@ -141,7 +141,7 @@ describe("user messages", () => {
describe("assistant messages", () => {
test("should convert assistant text messages", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [{ type: "text", text: "Hello back!" }],
@@ -160,7 +160,7 @@ describe("assistant messages", () => {
})
test("should handle assistant message with null content when only tool calls", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -195,7 +195,7 @@ describe("assistant messages", () => {
})
test("should concatenate multiple text parts", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -211,7 +211,7 @@ describe("assistant messages", () => {
describe("tool calls", () => {
test("should stringify arguments to tool calls", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -262,7 +262,7 @@ describe("tool calls", () => {
})
test("should handle text output type in tool results", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "tool",
content: [
@@ -286,7 +286,7 @@ describe("tool calls", () => {
})
test("should handle multiple tool results as separate messages", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "tool",
content: [
@@ -320,7 +320,7 @@ describe("tool calls", () => {
})
test("should handle text plus multiple tool calls", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -373,7 +373,7 @@ describe("tool calls", () => {
describe("reasoning (copilot-specific)", () => {
test("should omit reasoning_text without reasoning_opaque", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -395,7 +395,7 @@ describe("reasoning (copilot-specific)", () => {
})
test("should include reasoning_opaque from providerOptions", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -423,7 +423,7 @@ describe("reasoning (copilot-specific)", () => {
})
test("should include reasoning_opaque from text part providerOptions", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -450,7 +450,7 @@ describe("reasoning (copilot-specific)", () => {
})
test("should handle reasoning-only assistant message", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "assistant",
content: [
@@ -479,7 +479,7 @@ describe("reasoning (copilot-specific)", () => {
describe("full conversation", () => {
test("should convert a multi-turn conversation with reasoning", () => {
const result = convertToCopilotMessages([
const result = convertToOpenAICompatibleChatMessages([
{
role: "system",
content: "You are a helpful assistant.",
+3 -19
View File
@@ -42,7 +42,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { toolDefinitions } from "./lib/tool"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
import { Reference } from "../src/reference"
@@ -701,25 +701,9 @@ describe("LocationServiceMap", () => {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const supervisor = yield* PluginSupervisor.Service
yield* supervisor.flush
const registry = yield* Tool.Service
// Tool plugins register during the forked PluginSupervisor boot; wait for
// every expected tool rather than relying on batch ordering.
yield* Effect.forEach(
[
"edit",
"glob",
"grep",
"question",
"read",
"shell",
"skill",
"subagent",
"webfetch",
"websearch",
"write",
],
(name) => waitForTool(registry, name),
)
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(registry),
+92 -1
View File
@@ -1,6 +1,7 @@
import { afterAll, describe, expect, test } from "bun:test"
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/core/integration"
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
import { Effect } from "effect"
@@ -27,6 +28,96 @@ const authorize = (redirect_uri?: string) =>
)
describe("MCP OAuth", () => {
test("completes interactive authorization through the loopback callback", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({
access_token: "access",
token_type: "Bearer",
refresh_token: "refresh",
expires_in: 3600,
})
},
})
const credential = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({
type: "remote",
url: server.url.href,
oauth: { client_id: "client" },
}),
methodID: Integration.MethodID.make("oauth"),
})
const authorizationUrl = new URL(authorization.url)
const redirectValue = authorizationUrl.searchParams.get("redirect_uri")
const state = authorizationUrl.searchParams.get("state")
if (!redirectValue || !state) throw new Error("Missing OAuth redirect parameters")
const redirect = new URL(redirectValue)
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", state)
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
return yield* authorization.callback
}),
),
).finally(() => server.stop(true))
expect(credential.access).toBe("access")
expect(credential.refresh).toBe("refresh")
expect(tokenRequests).toHaveLength(1)
expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code")
expect(tokenRequests[0]?.get("code")).toBe("accepted")
expect(tokenRequests[0]?.get("code_verifier")).not.toBeNull()
})
test("refreshes tokens loaded from a persisted credential", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "next", token_type: "Bearer" })
},
})
const store = McpOAuth.memoryStore()
await store.saveTokens(
McpOAuth.toTokens(
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "expired",
refresh: "refresh",
expires: Date.now() - 1000,
metadata: { serverUrl: server.url.href, tokenType: "Bearer" },
}),
),
)
const oauthProvider = McpOAuth.provider({
redirectUrl: "http://127.0.0.1/callback",
client: { id: "client" },
onRedirect: () => undefined,
store,
})
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
expect(result).toBe("AUTHORIZED")
expect(await store.tokens()).toEqual({ access_token: "next", token_type: "Bearer", refresh_token: "refresh" })
expect(tokenRequests).toHaveLength(1)
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
})
test("shares concurrent refreshes for the same token", async () => {
let requests = 0
const pending = Promise.withResolvers<void>()
+54 -8
View File
@@ -44,7 +44,7 @@ import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
@@ -474,6 +474,37 @@ test("retains output schemas across paginated MCP discovery", async () => {
])
})
test("lists paginated prompts and invokes them through the MCP client", async () => {
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* connect(
"prompts",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-prompts.ts")],
}),
import.meta.dir,
)
return {
prompts: yield* connection.prompts(),
result: yield* connection.prompt({ name: "first", args: { topic: "Effect" } }),
}
}),
),
)
expect(result.prompts).toEqual([
{
name: "first",
description: "First prompt",
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
},
{ name: "second", description: "Second prompt", arguments: undefined },
])
expect(result.result).toEqual({ messages: [{ role: "user", content: { type: "text", text: "Effect" } }] })
})
test("spawns local MCP servers through the location environment", async () => {
const spawns: Array<ChildProcess.Command> = []
const cwd = path.join(import.meta.dir, "fixture")
@@ -1563,7 +1594,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
@@ -1582,7 +1615,11 @@ it.effect("returns content-only MCP results through Code Mode", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
@@ -1605,7 +1642,8 @@ it.effect("returns content-only MCP results through Code Mode", () =>
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_lookup")
const registration = yield* McpTool.Service
yield* registration.flush
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")
@@ -1621,7 +1659,8 @@ it.effect("fails the call when MCP reports isError", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_fail")
const registration = yield* McpTool.Service
yield* registration.flush
const execution = yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_is_error"),
@@ -1639,7 +1678,8 @@ it.effect("preserves MCP text and media content for the model", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_media")
const registration = yield* McpTool.Service
yield* registration.flush
const execution = yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_media"),
@@ -1662,7 +1702,10 @@ it.effect("waits for permission before calling an MCP tool", () =>
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const fiber = yield* toolSet
.execute({
@@ -1703,7 +1746,10 @@ it.effect("does not call MCP when permission is blocked", () =>
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_blocked"),
+1 -21
View File
@@ -14,6 +14,7 @@ import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { withEnv } from "../fixture/env"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
@@ -29,27 +30,6 @@ const it = testEffect(layer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
Object.entries(variables).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
describe("ModelsDevPlugin", () => {
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
@@ -7,6 +7,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -15,32 +16,10 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareAIGatewayPlugin.effect(host)
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
fx,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = []
const gatewayModelCalls: unknown[] = []
@@ -108,9 +87,8 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
Effect.gen(function* () {
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
).toContainEqual({
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
form: [
@@ -132,7 +110,6 @@ describe("CloudflareAIGatewayPlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
@@ -153,7 +130,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -198,7 +174,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -228,7 +203,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -266,7 +240,6 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -298,7 +271,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -321,7 +293,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -345,7 +316,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -375,7 +345,6 @@ describe("CloudflareAIGatewayPlugin", () => {
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
@@ -384,9 +353,11 @@ describe("CloudflareAIGatewayPlugin", () => {
}),
)
yield* addPlugin()
expect(
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
).toContainEqual({ type: "key", label: "Gateway API token" })
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
})
const result = yield* aisdk.runSDK({
model: Model.Info.make({
@@ -408,7 +379,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -440,7 +410,6 @@ describe("CloudflareAIGatewayPlugin", () => {
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
@@ -156,7 +156,6 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
@@ -176,7 +175,6 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.effect("uses the model modelID for the default language model", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const language = yield* aisdk.language(
@@ -21,7 +21,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GithubCopilotPlugin.effect(host)
})
@@ -57,7 +56,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("registers GitHub Copilot device OAuth", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
id: Integration.MethodID.make("device"),
type: "oauth",
label: "Login with GitHub Copilot",
@@ -124,7 +124,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("adds Copilot authentication to native Anthropic requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
@@ -145,7 +146,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies title generation as a background interaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_title"),
agent: Agent.ID.make("title"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
@@ -158,7 +160,8 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies compaction requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_compaction"),
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
@@ -170,7 +173,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
@@ -221,7 +223,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("selects languageModel when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -240,7 +241,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -259,7 +259,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -320,7 +319,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -350,7 +348,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("uses the API model ID when selecting responses or chat", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -417,7 +414,6 @@ describe("GithubCopilotPlugin", () => {
it.effect("ignores non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
@@ -1,12 +1,12 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -15,32 +15,10 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GitLabPlugin.effect(host)
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
createGitLab: (options: Record<string, unknown>) => {
@@ -64,7 +42,6 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
@@ -102,7 +79,6 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
@@ -128,7 +104,6 @@ describe("GitLabPlugin", () => {
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
yield* aisdk.runSDK({
@@ -170,7 +145,6 @@ describe("GitLabPlugin", () => {
it.effect("ignores non-GitLab SDK packages", () =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
@@ -189,7 +163,6 @@ describe("GitLabPlugin", () => {
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -223,7 +196,6 @@ describe("GitLabPlugin", () => {
it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -251,7 +223,6 @@ describe("GitLabPlugin", () => {
it.effect("uses provider feature flags instead of model settings feature flags", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -278,7 +249,6 @@ describe("GitLabPlugin", () => {
it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: [string, unknown][] = []
yield* addPlugin()
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
})
describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")
})
it.effect("applies legacy referer headers only to Kilo endpoints", () =>
Effect.gen(function* () {
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
@@ -15,14 +15,13 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const integration = yield* Integration.Service
yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
yield* LLMGatewayPlugin.effect(host)
})
describe("LLMGatewayPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")
})
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () {
@@ -9,7 +9,7 @@ import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -38,12 +38,10 @@ function eventually<A>(
}
describe("LMStudioPlugin", () => {
it.effect("is registered as a built-in provider plugin", () =>
Effect.sync(() => {
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
}),
)
test("is registered as a built-in provider plugin", () => {
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
})
it.live("discovers local language models with their capabilities and effective context", () =>
Effect.acquireUseRelease(
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
})
describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")
})
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () {
@@ -28,8 +28,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
yield* OpenAIPlugin.effect(host)
})
const addGithubCopilotPlugin = Effect.fn(function* () {
@@ -65,7 +64,8 @@ describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([
{
id: Integration.MethodID.make("chatgpt-browser"),
type: "oauth",
@@ -3,13 +3,13 @@ import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -18,12 +18,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const bus = yield* Bus.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provideService(Integration.Service, integration),
)
yield* OpencodePlugin.effect(host)
})
function required<T>(value: T | undefined): T {
@@ -45,27 +40,6 @@ function eventually<A>(
})
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
const cost = (input: number, output = 0) => [
{
input: Money.USDPerMillionTokens.make(input),
@@ -81,7 +55,8 @@ describe("OpencodePlugin", () => {
it.effect("registers account and service account methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
const integrations = yield* Integration.Service
expect((yield* integrations.get(Integration.ID.make("opencode")))?.methods).toEqual([
{
id: Integration.MethodID.make("device"),
type: "oauth",
@@ -140,7 +115,8 @@ describe("OpencodePlugin", () => {
expect(requests).toContain("POST /console/auth/device/token")
expect(requests).toContain("GET /console/api/user")
expect(requests).toContain("GET /console/api/orgs")
expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
const credentials = yield* Credential.Service
expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
metadata: { server: `${server.url.origin}/console` },
})
}),
@@ -166,7 +142,8 @@ describe("OpencodePlugin", () => {
(server) =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
const integrations = yield* Integration.Service
const error = yield* integrations.oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
@@ -183,7 +160,8 @@ describe("OpencodePlugin", () => {
it.effect("rejects non-HTTP OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
const integrations = yield* Integration.Service
const error = yield* integrations.oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
@@ -198,7 +176,8 @@ describe("OpencodePlugin", () => {
it.effect("rejects non-string OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
const integrations = yield* Integration.Service
const error = yield* integrations.oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
@@ -299,7 +278,8 @@ describe("OpencodePlugin", () => {
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
headers: { "x-org-id": "org" },
})
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
const integrations = yield* Integration.Service
expect(yield* integrations.get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
expect(model).toMatchObject({
@@ -365,19 +345,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -392,19 +362,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("free")),
modelID: Model.ID.make("free"),
package: Provider.aisdk("test-provider"),
cost: cost(0),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("free"), (draft) => {
draft.cost = cost(0)
})
})
yield* addPlugin()
@@ -421,19 +381,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("output-only")),
modelID: Model.ID.make("output-only"),
package: Provider.aisdk("test-provider"),
cost: cost(0, 1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("output-only"), (draft) => {
draft.cost = cost(0, 1)
})
})
yield* addPlugin()
@@ -450,19 +400,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -484,19 +424,9 @@ describe("OpencodePlugin", () => {
})
})
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.opencode, () => {})
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -511,23 +441,12 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.opencode),
package: Provider.aisdk("test-provider"),
settings: { apiKey: "configured" },
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, (draft) => {
draft.package = provider.package
catalog.provider.update(Provider.ID.opencode, (draft) => {
draft.package = Provider.aisdk("test-provider")
draft.settings = { apiKey: "configured" }
})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -542,19 +461,9 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const provider = Provider.Info.make({
...Provider.Info.empty(Provider.ID.openai),
package: Provider.aisdk("test-provider"),
})
const model = Model.Info.make({
...Model.Info.default(provider.id, Model.ID.make("paid")),
modelID: Model.ID.make("paid"),
package: Provider.aisdk("test-provider"),
cost: cost(1),
})
catalog.provider.update(provider.id, () => {})
catalog.model.update(provider.id, model.id, (draft) => {
draft.cost = [...model.cost]
catalog.provider.update(Provider.ID.openai, () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("paid"), (draft) => {
draft.cost = cost(1)
})
})
yield* addPlugin()
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -19,9 +19,9 @@ const addPlugin = Effect.fn(function* () {
})
describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")),
)
test("is registered so legacy OpenRouter behavior can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")
})
it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () {
@@ -7,6 +7,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { Provider } from "@opencode-ai/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -20,32 +21,10 @@ const npm = Npm.Service.of({
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm))
})
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(vars)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}),
)
}
function model(providerID: string) {
return Model.Info.make({
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make("sap-model")),
@@ -60,7 +39,6 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -83,7 +61,6 @@ describe("SapAICorePlugin", () => {
},
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -102,7 +79,6 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -118,7 +94,6 @@ describe("SapAICorePlugin", () => {
it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
@@ -136,7 +111,6 @@ describe("SapAICorePlugin", () => {
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = yield* aisdk.runSDK({
@@ -14,7 +14,6 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* VercelPlugin.effect(host)
})
@@ -54,7 +53,6 @@ describe("VercelPlugin", () => {
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const event = yield* aisdk.runSDK({
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -23,9 +23,9 @@ function required<T>(value: T | undefined): T {
}
describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")),
)
test("is registered so legacy referer headers can be applied", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")
})
it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () {
+2 -2
View File
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { location } from "../fixture/location"
@@ -88,7 +88,7 @@ describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
const id = PtyID.make("pty_missing")
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
+1 -17
View File
@@ -7,8 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { branch, git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { branch, git, read, withRemote } from "./fixture/git"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
@@ -127,21 +126,6 @@ function cacheLayer(root: string) {
])
}
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(async () => {
const root = await tmpdir()
return { root, fixture: await gitRemote(root.path) }
}),
(input) => body(input.fixture),
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
)
}
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
}
function exists(file: string) {
return Effect.promise(() =>
fs.stat(file).then(
+2 -2
View File
@@ -379,7 +379,7 @@ describe("SessionRestart background recovery", () => {
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
])
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
@@ -413,7 +413,7 @@ describe("SessionRestart background recovery", () => {
text: expect.stringContaining("server restarted"),
metadata: {
source: "shell",
jobID: "call-background-shell",
jobID: "sh_background_orphan",
shellID: "sh_background_orphan",
state: "cancelled",
},
@@ -81,7 +81,7 @@ const testLayer = AppNodeBuilder.build(
[Config.node, config],
[Image.node, imageLayer],
],
) as unknown as Layer.Layer<unknown>
)
const it = testEffect(testLayer)
+126 -6
View File
@@ -11,6 +11,7 @@ import {
InvalidProviderOutputError,
InvalidRequestError,
RateLimitError,
UnknownProviderError,
} from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
@@ -847,18 +848,19 @@ function* verifyEphemeralDeltas(s: Scenario, kind: FragmentKind) {
function* verifyPartialFlushOnFailure(s: Scenario, kind: FragmentKind) {
const prompt = `Fail after ${kind}`
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
const failure = providerUnavailable()
// A non-retryable failure keeps the step terminal so the flushed fragments settle durably.
const failure = invalidRequest()
yield* s.admit(prompt)
yield* s.llm.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
expect(yield* s.resume.pipe(Effect.flip)).toBe(failure)
expect(yield* s.context).toMatchObject([
Expected.user(prompt),
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
kind === "tool input"
? Expected.failedTool(
{ id: fragmentID(kind, "partial") },
{ error: { type: "provider.transport", message: "Provider unavailable" } },
{ error: { type: "provider.invalid-request", message: "Invalid request" } },
)
: fixture.expectedContent,
]),
@@ -2903,6 +2905,54 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("keeps one durable reasoning part when reasoning closes after text", function* (s) {
yield* s.admit("Think and answer")
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed", index: 0 }]
yield* s.llm.push(
TestLLM.stop(
LLMEvent.reasoningStart({ id: "reasoning-0" }),
LLMEvent.reasoningDelta({ id: "reasoning-0", text: "thinking" }),
LLMEvent.textStart({ id: "text-0" }),
LLMEvent.textDelta({ id: "text-0", text: "Hello" }),
LLMEvent.textDelta({ id: "text-0", text: " world" }),
LLMEvent.reasoningEnd({
id: "reasoning-0",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
}),
LLMEvent.textEnd({ id: "text-0" }),
),
)
yield* s.resume
yield* replaySessionProjection(sessionID)
const assistant = requireAssistant(yield* s.context)
expect(assistant.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(yield* s.context).toMatchObject([
Expected.user("Think and answer"),
Expected.assistant({}, [
{
type: "reasoning",
text: "thinking",
state: { reasoningField: "reasoning", reasoningDetails: details },
},
{ type: "text", text: "Hello world" },
]),
])
yield* s.admit("Continue")
yield* s.llm.push([])
yield* s.resume
expect(s.requests[1]?.messages[1]?.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
},
])
})
scenario("restores durable text provider metadata in the next request", function* (s) {
yield* s.admit("Check first")
@@ -3961,7 +4011,8 @@ describe("SessionRunnerLLM", () => {
scenario("awaits started local tools before surfacing provider stream failure", function* (s) {
yield* s.admit("Settle before failing")
const failure = providerUnavailable()
// Non-retryable so the step settles terminally instead of continuing after tool output.
const failure = invalidRequest()
const tools = yield* s.blockTools()
yield* s.llm.push(
TestLLM.failAfter(
@@ -4516,6 +4567,74 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("continues after a mid-stream rate limit honoring retry-after", function* (s) {
yield* s.admit("Continue after rate limit")
yield* s.llm.push(
TestLLM.failAfter(
rateLimited(5_000),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "rate-limited-partial" }),
LLMEvent.textDelta({ id: "rate-limited-partial", text: "Partial" }),
),
)
yield* s.llm.push(TestLLM.text(" continuation", "rate-limit-continuation"))
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages.at(-2)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "Partial" }],
})
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(yield* s.context).toMatchObject([
Expected.user("Continue after rate limit"),
Expected.assistant({ finish: "error", error: { type: "provider.rate-limit" } }, [Expected.text("Partial")]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
])
})
scenario("continues after an unrecognized mid-stream provider failure", function* (s) {
const failure = new AIError({ reason: new UnknownProviderError({ message: "Provider returned error" }) })
yield* s.admit("Continue after unknown failure")
yield* s.llm.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "unknown-failure-partial" }),
LLMEvent.textDelta({ id: "unknown-failure-partial", text: "Partial" }),
),
)
yield* s.llm.push(TestLLM.text(" continuation", "unknown-failure-continuation"))
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* s.context).toMatchObject([
Expected.user("Continue after unknown failure"),
Expected.assistant({ finish: "error", error: { type: "provider.unknown" } }, [Expected.text("Partial")]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
])
})
scenario("lowers interrupted reasoning before continuing an incomplete stream", function* (s) {
yield* s.admit("Continue interrupted reasoning")
yield* s.llm.push(
@@ -5273,7 +5392,8 @@ describe("SessionRunnerLLM", () => {
})
scenario("durably fails a hosted tool left unresolved by a raw provider stream failure", function* (s) {
const failure = providerUnavailable()
// Non-retryable so the step settles terminally instead of continuing after tool output.
const failure = invalidRequest()
yield* s.llm.push(
Stream.concat(
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
@@ -5297,7 +5417,7 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
expect(yield* s.context).toMatchObject([
Expected.user("Fail hosted tool on raw failure"),
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
Expected.failedTool({ id: "call-hosted-raw-failure" }, {}),
]),
])
+34
View File
@@ -678,6 +678,40 @@ describe("ReadTool", () => {
}),
)
it.effect("normalizes a zero directory offset in the model heading", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.ListPage({
type: "list-page",
entries: [FileSystem.Entry.make({ path: RelativePath.make("index.ts"), type: "file" })],
truncated: true,
next: 2,
})
const registry = yield* Tool.Service
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory-zero",
name: "read",
input: { path: "src", offset: 0, limit: 1 },
},
})
expect(result.status).toBe("completed")
if (result.status !== "completed") return
expect(result.content).toEqual([
{
type: "text",
text: "Read directory src, entries 1-1\nindex.ts\n[Output truncated. Continue reading with offset: 2]",
},
])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 0, limit: 1 } },
])
}),
)
it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () {
allow = false
+26 -21
View File
@@ -1,10 +1,21 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { z } from "zod"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Tool } from "../src/tool"
import { definition, execute } from "../src/tool/runtime"
const context = {
sessionID: Session.ID.make("ses_tool_schema"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_tool_schema"),
id: Tool.CallID.make("call_tool_schema"),
progress: () => Effect.void,
} satisfies Tool.Context
test("tools are structural values", async () => {
const config = {
name: "foreign",
@@ -136,7 +147,7 @@ test("portable schemas validate and describe typed tools", async () => {
inputSchema: { type: "object", properties: { count: { type: "string" } } },
outputSchema: { type: "string" },
})
const result = await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))
const result = await Effect.runPromise(execute(tool, { count: "41" }, context))
expect(result.output).toBe("42")
})
@@ -166,10 +177,10 @@ test("Zod schemas validate, transform, and describe typed tools", async () => {
additionalProperties: false,
},
})
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { count: "41" }, context))).toMatchObject({
output: { count: 42 },
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
@@ -205,7 +216,7 @@ test("portable schema failures become tool failures", async () => {
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
context,
),
),
)
@@ -228,9 +239,7 @@ test("Effect schema failures use normalized input issues", async () => {
execute: () => Effect.succeed({ content: "unused" }),
}
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
@@ -259,7 +268,7 @@ test("input error prompts limit normalized issues", async () => {
execute: () => Effect.succeed({ content: "unused" }),
}
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
@@ -278,7 +287,7 @@ test("canonical results carry metadata with typed output", async () => {
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
}
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(tool.execute({ value: "out" }, context))).toEqual({
output: { value: "out", internal: true },
metadata: { value: "out" },
content: "out",
@@ -312,33 +321,29 @@ test("raw JSON schemas validate and decode tool input", async () => {
description: "Raw tool",
inputSchema: input,
})
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, context))).toEqual({
output: undefined,
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
@@ -359,10 +364,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => {
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { value: "ok" }, context))).toMatchObject({
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
@@ -381,7 +386,7 @@ test("raw JSON schemas pass input through when they cannot be imported", async (
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, context))).toMatchObject({
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
})
})
+41 -4
View File
@@ -702,6 +702,42 @@ describe("ShellTool ordinary shell syntax", () => {
})
describe("ShellTool", () => {
it.live("returns both parallel CodeMode shell results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
yield* registry.transform((draft) =>
draft.update("shell", (tool) => {
tool.options = { ...tool.options, codemode: true }
}),
)
const command = isWindows ? helloCommand : `${helloCommand}; sleep 0.1`
const inputs = ["one", "two"].map((text) => JSON.stringify({ command: command.replace("hello", text) }))
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-parallel-shells",
name: "execute",
input: { code: `return await Promise.all([tools.shell(${inputs[0]}), tools.shell(${inputs[1]})])` },
},
}).pipe(Effect.timeout("3 seconds"))
expect(result.status).toBe("completed")
expect(JSON.parse(result.output.output)).toEqual([
{ output: "one", exit: 0, truncated: false, status: "completed" },
{ output: "two", exit: 0, truncated: false, status: "completed" },
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
productionIt.live(
"registers and returns real successful output from the active Location",
() =>
@@ -1342,7 +1378,7 @@ describe("ShellTool", () => {
description: bodyExitCommand,
metadata: {
source: "shell",
jobID: "call-background-nonzero",
jobID: shellID,
shellID,
state: "completed",
exit: 7,
@@ -1375,7 +1411,7 @@ describe("ShellTool", () => {
)
: Effect.void,
)
yield* executeTool(registry, {
const settled = yield* executeTool(registry, {
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
// The command can finish while its initial progress update is being published.
progress: (update) =>
@@ -1386,7 +1422,7 @@ describe("ShellTool", () => {
expect(yield* Deferred.await(persisted)).toMatchObject([
{
id: "call-background-silent-nonzero",
id: settled.metadata?.shellID,
status: "completed",
output: "(no output)\n\nCommand exited with code 7.",
},
@@ -1519,9 +1555,10 @@ describe("ShellTool", () => {
yield* Effect.promise(() => Bun.sleep(1))
return yield* backgroundWhenReady(remaining - 1)
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const backgrounded = yield* backgroundWhenReady()
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(backgrounded).toMatchObject([{ id: shellID, type: "shell" }])
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")
+21 -82
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import { spawn } from "child_process"
import fs from "fs/promises"
import path from "path"
import os from "os"
@@ -9,15 +8,13 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
import { runLockWorker, spawnLockWorker, stopLockWorker, waitForFile } from "../fixture/lock-worker"
import { tmpdir } from "../fixture/tmpdir"
function lock(dir: string, key: string) {
return path.join(dir, Hash.fast(key) + ".lock")
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function exists(file: string) {
return fs
.stat(file)
@@ -42,59 +39,8 @@ type Msg = {
done?: string
}
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts")
function run(msg: Msg) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
})
})
}
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
await closed
return
}
await new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
await closed
}
async function waitForFile(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await exists(file)) return
await sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
// ---------------------------------------------------------------------------
// Test layer
// ---------------------------------------------------------------------------
@@ -122,14 +68,13 @@ describe("util.effect-flock", () => {
"acquire and release via scoped Effect",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const lockDir = lock(dir, "eflock:acquire")
yield* Effect.scoped(flock.acquire("eflock:acquire", dir))
expect(yield* Effect.promise(() => exists(lockDir))).toBe(false)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -137,7 +82,7 @@ describe("util.effect-flock", () => {
"supports an acquisition timeout",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:timeout"
@@ -150,7 +95,6 @@ describe("util.effect-flock", () => {
expect(error._tag).toBe("LockTimeoutError")
}),
)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -158,7 +102,7 @@ describe("util.effect-flock", () => {
"withLock data-first",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
let hit = false
@@ -170,7 +114,6 @@ describe("util.effect-flock", () => {
dir,
)
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -178,7 +121,7 @@ describe("util.effect-flock", () => {
"withLock pipeable",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
let hit = false
@@ -186,7 +129,6 @@ describe("util.effect-flock", () => {
hit = true
}).pipe(flock.withLock("eflock:pipe", dir))
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -194,7 +136,7 @@ describe("util.effect-flock", () => {
"writes owner metadata",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:meta"
const file = path.join(lock(dir, key), "meta.json")
@@ -211,7 +153,6 @@ describe("util.effect-flock", () => {
expect(typeof json.createdAt).toBe("string")
}),
)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -219,7 +160,7 @@ describe("util.effect-flock", () => {
"breaks stale lock dirs",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:stale"
const lockDir = lock(dir, key)
@@ -239,7 +180,6 @@ describe("util.effect-flock", () => {
dir,
)
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -247,7 +187,7 @@ describe("util.effect-flock", () => {
"recovers from stale breaker",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:stale-breaker"
const lockDir = lock(dir, key)
@@ -271,7 +211,6 @@ describe("util.effect-flock", () => {
)
expect(hit).toBe(true)
expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -279,7 +218,7 @@ describe("util.effect-flock", () => {
"detects compromise when lock dir removed",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:compromised"
const lockDir = lock(dir, key)
@@ -294,7 +233,6 @@ describe("util.effect-flock", () => {
expect(Exit.isFailure(result)).toBe(true)
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -302,7 +240,7 @@ describe("util.effect-flock", () => {
"detects token mismatch",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
const key = "eflock:token"
const lockDir = lock(dir, key)
@@ -323,7 +261,6 @@ describe("util.effect-flock", () => {
expect(Exit.isFailure(result)).toBe(true)
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
@@ -332,18 +269,18 @@ describe("util.effect-flock", () => {
Effect.gen(function* () {
if (process.platform === "win32") return
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
const dir = path.join(tmp, "locks")
yield* Effect.promise(async () => {
await fs.mkdir(dir, { recursive: true })
await fs.chmod(dir, 0o500)
})
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(dir, 0o700)))
yield* Effect.promise(() => fs.chmod(dir, 0o500))
const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
// oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
expect(String(result)).toContain("PermissionDenied")
yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
}),
)
@@ -359,7 +296,9 @@ describe("util.effect-flock", () => {
try {
const out = await Promise.all(
Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
Array.from({ length: n }, () =>
runLockWorker(worker, { key: "eflock:stress", dir, done, active, holdMs: 30 } satisfies Msg),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
@@ -385,11 +324,11 @@ describe("util.effect-flock", () => {
const dir = path.join(tmp, "locks")
const ready = path.join(tmp, "ready")
const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
const proc = spawnLockWorker(worker, { key: "eflock:crash", dir, ready, holdMs: 120_000 } satisfies Msg)
try {
await waitForFile(ready, 5_000)
await stopWorker(proc)
await stopLockWorker(proc)
// Backdate lock files so they're past STALE_MS (60s)
const lockDir = lock(dir, "eflock:crash")
@@ -399,11 +338,11 @@ describe("util.effect-flock", () => {
await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
const done = path.join(tmp, "done.log")
const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
const result = await runLockWorker(worker, { key: "eflock:crash", dir, done, holdMs: 10 } satisfies Msg)
expect(result.code).toBe(0)
expect(result.stderr.toString()).toBe("")
} finally {
await stopWorker(proc).catch(() => {})
await stopLockWorker(proc).catch(() => {})
await fs.rm(tmp, { recursive: true, force: true })
}
}),
+39 -112
View File
@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { spawn } from "child_process"
import path from "path"
import os from "os"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { runLockWorker, spawnLockWorker, stopLockWorker, waitForFile } from "../fixture/lock-worker"
import { tmpdir } from "../fixture/tmpdir"
type Msg = {
key: string
@@ -19,29 +19,12 @@ type Msg = {
done?: string
}
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
async function tmpdir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-"))
return {
path: dir,
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
}
function lock(dir: string, key: string) {
return path.join(dir, Hash.fast(key) + ".lock")
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
async function exists(file: string) {
return fs
.stat(file)
@@ -49,73 +32,13 @@ async function exists(file: string) {
.catch(() => false)
}
async function wait(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await exists(file)) return
await sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
function run(msg: Msg) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
})
})
})
}
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
await closed
return
}
await new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
await closed
}
async function readJson<T>(p: string): Promise<T> {
return JSON.parse(await fs.readFile(p, "utf8"))
}
describe("util.flock", () => {
test("enforces mutual exclusion under process contention", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const done = path.join(tmp.path, "done.log")
const active = path.join(tmp.path, "active")
@@ -124,7 +47,7 @@ describe("util.flock", () => {
const out = await Promise.all(
Array.from({ length: n }, () =>
run({
runLockWorker(worker, {
key,
dir,
done,
@@ -132,7 +55,7 @@ describe("util.flock", () => {
holdMs: 30,
staleMs: 1_000,
timeoutMs: 15_000,
}),
} satisfies Msg),
),
)
@@ -147,21 +70,21 @@ describe("util.flock", () => {
}, 20_000)
test("times out while waiting when lock is still healthy", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:timeout"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({
const proc = spawnLockWorker(worker, {
key,
dir,
ready,
holdMs: 20_000,
staleMs: 10_000,
timeoutMs: 30_000,
})
} satisfies Msg)
try {
await wait(ready, 5_000)
await waitForFile(ready, 5_000)
const seen: string[] = []
const err = await Flock.withLock(key, async () => {}, {
dir,
@@ -178,45 +101,49 @@ describe("util.flock", () => {
expect(seen.length).toBeGreaterThan(0)
expect(seen.every((x) => x === key)).toBe(true)
} finally {
await stopWorker(proc).catch(() => undefined)
await stopLockWorker(proc).catch(() => undefined)
}
}, 15_000)
test("recovers after a crashed lock owner", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:crash"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({
const proc = spawnLockWorker(worker, {
key,
dir,
ready,
holdMs: 20_000,
staleMs: 500,
timeoutMs: 30_000,
})
} satisfies Msg)
await wait(ready, 5_000)
await stopWorker(proc)
try {
await waitForFile(ready, 5_000)
await stopLockWorker(proc)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 500,
timeoutMs: 8_000,
},
)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 500,
timeoutMs: 8_000,
},
)
expect(hit).toBe(true)
expect(hit).toBe(true)
} finally {
await stopLockWorker(proc)
}
}, 20_000)
test("breaks stale lock dirs when heartbeat is missing", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:missing-heartbeat"
const lockDir = lock(dir, key)
@@ -242,7 +169,7 @@ describe("util.flock", () => {
})
test("recovers when a stale breaker claim was left behind", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:stale-breaker"
const lockDir = lock(dir, key)
@@ -273,7 +200,7 @@ describe("util.flock", () => {
})
test("fails clearly if lock dir is removed while held", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:compromised"
const lockDir = lock(dir, key)
@@ -313,7 +240,7 @@ describe("util.flock", () => {
})
test("writes owner metadata while lock is held", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:meta"
const file = path.join(lock(dir, key), "meta.json")
@@ -342,7 +269,7 @@ describe("util.flock", () => {
})
test("supports acquire with await using", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:acquire"
const lockDir = lock(dir, key)
@@ -360,7 +287,7 @@ describe("util.flock", () => {
})
test("refuses token mismatch release and recovers from stale", async () => {
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:token"
const lockDir = lock(dir, key)
@@ -403,7 +330,7 @@ describe("util.flock", () => {
test("fails clearly on unwritable lock roots", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
await using tmp = await tmpdir("flock-test-")
const dir = path.join(tmp.path, "locks")
const key = "flock:perm"
+13 -39
View File
@@ -15,6 +15,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { initRepo } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -29,15 +30,6 @@ function abs(input: string) {
const gitWorktree = Worktree.StrategyID.make("git")
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
function setup() {
return Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
@@ -85,9 +77,7 @@ describe("Worktree", () => {
)
yield* Effect.promise(() => initRepo(root.path))
const linked = `${root.path}-linked`
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(linked, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(linked, { recursive: true, force: true })))
yield* Effect.promise(() => $`git worktree add ${linked} -b linked-${Date.now()}`.cwd(root.path).quiet())
const project = yield* Project.Service
@@ -162,9 +152,7 @@ describe("Worktree", () => {
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
const target = abs(path.join(parent, "worktree"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const fiber = yield* bus
.subscribe(Worktree.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -198,9 +186,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* input.db
.update(ProjectTable)
.set({
@@ -284,9 +270,7 @@ describe("Worktree", () => {
const input = yield* setup()
const worktree = yield* Worktree.Service
const parent = abs(`${input.root.path}-branch-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(async () => {
await $`git branch feature-base`.cwd(input.sourceDirectory).quiet()
})
@@ -312,9 +296,7 @@ describe("Worktree", () => {
const input = yield* setup()
const worktree = yield* Worktree.Service
const parent = abs(`${input.root.path}-option-worktree`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const error = yield* worktree
.create({
@@ -360,8 +342,8 @@ describe("Worktree", () => {
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
yield* Effect.addFinalizer(() =>
Effect.all([
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })),
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })),
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
@@ -397,9 +379,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
@@ -455,9 +435,7 @@ describe("Worktree", () => {
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
const target = abs(path.join(parent, "worktree-3"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
@@ -487,9 +465,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 10 }, (_, index) =>
@@ -546,7 +522,7 @@ describe("Worktree", () => {
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Promise.all([target, unchanged].map((item) => fs.rm(item, { recursive: true, force: true }))),
).pipe(Effect.ignore),
).pipe(Effect.asVoid),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree add --detach ${unchanged} HEAD`.cwd(input.root.path).quiet())
@@ -594,9 +570,7 @@ describe("Worktree", () => {
const worktree = yield* Worktree.Service
const stale = abs(`${input.root.path}-worktree-stale`)
const target = abs(`${input.root.path}-worktree-after-stale`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(target, { recursive: true, force: true })))
yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
+21 -7
View File
@@ -5,7 +5,9 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
## Defining A Plugin
@@ -46,12 +48,15 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = "Reviews code for regressions"
item.description = description
item.mode = "subagent"
})
})
@@ -64,8 +69,12 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -81,7 +90,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
event.language = event.sdk.responses(event.model.modelID)
})
```
@@ -94,14 +103,15 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
Promise tools use complete executable tool values with async executors:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add("echo", {
tools.add({
name: "echo",
options: { codemode: false },
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -132,6 +142,10 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+17 -5
View File
@@ -31,7 +31,9 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains:
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
```ts
yield *
@@ -52,8 +54,12 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -72,10 +78,12 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -117,6 +125,10 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+283 -3
View File
@@ -790,6 +790,16 @@
"type": "null"
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
@@ -977,6 +987,16 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -988,7 +1008,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location.",
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -9872,6 +9892,97 @@
"x-websocket": true
}
},
"/api/experimental/session/{sessionID}/terminal/read": {
"get": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.read",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "lines",
"in": "query",
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.ReadLinesEncoded"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.ReadResult"
},
{
"type": "null"
}
]
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.",
"summary": "Read the session's most recently controlled terminal"
}
},
"/api/experimental/session/{sessionID}/terminal": {
"get": {
"tags": ["persistentPty"],
@@ -10074,6 +10185,70 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -14983,6 +15158,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15493,6 +15671,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15870,7 +16051,34 @@
"additionalProperties": false
}
},
"required": ["command", "args", "cwd", "title", "env"],
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -15967,6 +16175,69 @@
],
"additionalProperties": false
},
"PersistentPty.ReadLinesEncoded": {
"type": "string"
},
"PersistentPty.ReadResult": {
"type": "object",
"properties": {
"ptyID": {
"type": "string",
"pattern": "^pty"
},
"title": {
"type": "string"
},
"cwd": {
"type": "string"
},
"foregroundProcess": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"screen": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"cols": {
"type": "integer",
"exclusiveMinimum": 0
},
"rows": {
"type": "integer",
"exclusiveMinimum": 0
},
"cursor": {
"type": "object",
"properties": {
"x": {
"type": "integer",
"minimum": 0
},
"y": {
"type": "integer",
"minimum": 0
}
},
"required": ["x", "y"],
"additionalProperties": false
}
},
"required": ["text", "cols", "rows", "cursor"],
"additionalProperties": false
}
},
"required": ["ptyID", "title", "cwd", "foregroundProcess", "screen"],
"additionalProperties": false
},
"PersistentPty.Snapshot": {
"type": "object",
"properties": {
@@ -16222,7 +16493,7 @@
},
"Project.Vcs": {
"type": "string",
"enum": ["git", "hg"]
"pattern": "^[a-z][a-z0-9._-]*$"
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -16983,6 +17254,9 @@
"subpath": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/Session.Metadata"
},
"revert": {
"$ref": "#/components/schemas/Session.Revert"
}
@@ -17040,6 +17314,9 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
@@ -17831,6 +18108,9 @@
"required": ["id", "time", "text", "type"],
"additionalProperties": false
},
"Session.Metadata": {
"type": "object"
},
"Session.Revert": {
"type": "object",
"properties": {
+11 -18
View File
@@ -1318,24 +1318,7 @@ export function Prompt(props: PromptProps) {
restoreEntry()
return true
}
if (
session?.model?.providerID !== selection.providerID ||
session.model.id !== selection.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
) {
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(target, model)
const switchError = await client.api.session.switchModel({ sessionID: target, model }).then(
() => undefined,
(error) => error,
)
if (switchError) {
cancelCommit()
toast.show({ title: "Failed to switch model", message: errorMessage(switchError), variant: "error" })
restoreEntry()
return true
}
}
const model = { providerID: selection.providerID, id: selection.modelID, variant }
if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID: target }).then(
() => undefined,
@@ -1384,6 +1367,16 @@ export function Prompt(props: PromptProps) {
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
gate: newSession?.gate,
prepare: () => {
// Commit the captured selection after earlier admissions, including
// compaction setup. Cached state may still precede their SSE echoes;
// the server makes an unchanged selection a no-op.
const cancelCommit = local.model.trackSessionCommit(target, model)
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
cancelCommit()
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
},
})
.catch((error) => {
if (newSession) return newSession.recover(error)
+50 -9
View File
@@ -1,4 +1,4 @@
import { RGBA } from "@opentui/core"
import { RGBA, type MouseEvent } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import { useConfig } from "../config"
@@ -6,9 +6,12 @@ import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { useStorage } from "../context/storage"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
import { TerminalPane } from "./terminal-pane"
@@ -20,6 +23,32 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const toast = useToast()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
const terminalResize = createPaneResize({
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
defaultValue: defaultTerminalWidth,
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
void updateLayout((draft) => {
draft.terminalWidth = width
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let resizeRelease = false
const finishTerminalResize = (event: MouseEvent) => {
if (terminalResize.resizing()) {
// A captured drag-end can be followed by mouse-up on the focus overlay.
resizeRelease = true
queueMicrotask(() => {
resizeRelease = false
})
}
terminalResize.onMouseUp(event)
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
@@ -96,7 +125,16 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}))
return (
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row" position="relative">
<box
flexGrow={1}
minWidth={0}
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={terminalResize.onMouseDrag}
onMouseDragEnd={finishTerminalResize}
onMouseUp={finishTerminalResize}
>
<box
flexGrow={1}
flexBasis={0}
@@ -124,18 +162,17 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
height="100%"
zIndex={1}
// Consume the release before revealing permission buttons underneath.
onMouseUp={focusSession}
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<box
flexShrink={0}
width={
rightPane() === "terminal"
? Math.max(1, Math.floor((dimensions().width - props.verticalTabsWidth) / 2))
: SESSION_SIDEBAR_WIDTH
}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
minWidth={0}
minHeight={0}
>
@@ -146,6 +183,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
@@ -163,6 +201,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle resize={terminalResize} left={availableWidth() - terminalResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
position="absolute"
@@ -23,6 +23,7 @@ type StreamItem =
export function TerminalPane(props: {
ptyID: string
resizing?: boolean
autoFocus?: boolean
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | undefined) => void
@@ -77,6 +78,10 @@ export function TerminalPane(props: {
send(interactionFrame(size))
}
createEffect(() => {
if (props.resizing) interact()
})
const sendInput = (data: Uint8Array) => {
if (!restored) {
pendingInput.push(data)
+12 -10
View File
@@ -845,18 +845,20 @@ export function Session(props: {
slash: {
name: "compact",
},
run: async () => {
run: () => {
const selection = local.model.current()
if (selection)
await client.api.session.switchModel({
void data.session
.compact({
sessionID: route.sessionID,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant: local.model.variant.current(),
},
model: selection
? {
providerID: selection.providerID,
id: selection.modelID,
variant: local.model.variant.current(),
}
: undefined,
})
await client.api.session.compact({ sessionID: route.sessionID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error" }))
dialog.clear()
},
},
@@ -2037,7 +2039,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const source = () => stringValue(metadata()?.source)
const target = createMemo<BackgroundToolTarget | undefined>(() => {
if (source() === "shell") {
const id = stringValue(metadata()?.jobID)
const id = stringValue(metadata()?.shellID) ?? stringValue(metadata()?.jobID)
return id ? { source: "shell", id } : undefined
}
if (source() === "subagent") {
+8 -1
View File
@@ -410,10 +410,17 @@ export function backgroundToolRowIndex(
const end = rows.findIndex((row) => row.type === "message" && row.messageID === beforeMessageID)
return rows.slice(0, end === -1 ? rows.length : end).findLastIndex((row) => {
if (row.type !== "part") return false
if (target.source === "shell") return row.ref.partID === target.id
if (target.source === "shell" && row.ref.partID === target.id) return true
const message = byID.get(row.ref.messageID)
if (message?.type !== "assistant") return false
const part = resolvePart(message, row.ref.partID)
if (target.source === "shell")
return (
part?.type === "tool" &&
part.name.toLowerCase() === "shell" &&
part.state.status !== "streaming" &&
part.state.metadata?.shellID === target.id
)
return (
part?.type === "tool" &&
part.name.toLowerCase() === "subagent" &&
+6
View File
@@ -13,3 +13,9 @@ export function clampSessionTabsWidth(width: number, total: number) {
Math.min(width, SESSION_SIDEBAR_MAX_WIDTH, total - SESSION_CONTENT_MIN_WIDTH),
)
}
export function clampTerminalPaneWidth(width: number, total: number) {
const half = Math.max(1, Math.floor(total / 2))
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
}
+6 -1
View File
@@ -1,7 +1,11 @@
import { useTheme } from "../context/theme"
import type { createPaneResize } from "./pane-resize"
export function PaneResizeHandle(props: { resize: ReturnType<typeof createPaneResize>; left: number }) {
export function PaneResizeHandle(props: {
resize: ReturnType<typeof createPaneResize>
left: number
highlight?: "left" | "right"
}) {
const theme = useTheme("elevated")
return (
@@ -19,6 +23,7 @@ export function PaneResizeHandle(props: { resize: ReturnType<typeof createPaneRe
<box
width={1}
height="100%"
marginLeft={props.highlight === "right" ? 1 : 0}
backgroundColor={
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
}
@@ -169,7 +169,13 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
test("finds background tool launch rows for completion navigation", () => {
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [
{ type: "tool", id: "shell-1", name: "shell", state: pending(), time: { created: 1 } },
{
type: "tool",
id: "shell-1",
name: "shell",
state: completed({ shellID: "sh_first", status: "running" }),
time: { created: 1 },
},
]),
assistant("assistant-2", [
{
@@ -214,6 +220,7 @@ test("finds background tool launch rows for completion navigation", () => {
const rows = reduceSessionRows(messages)
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "shell-1" }, "completion-2")).toBe(0)
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "sh_first" }, "completion-2")).toBe(0)
expect(backgroundToolRowIndex(rows, messages, { source: "subagent", id: "child-1" }, "completion-1")).toBe(1)
expect(backgroundToolRowIndex(rows, messages, { source: "subagent", id: "child-1" }, "completion-2")).toBe(3)
})
@@ -0,0 +1,235 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { InputRenderable } from "@opentui/core"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([70, 120])(
"/compact renders before model setup, suppresses repeat gestures, and toasts rollback at %i columns",
async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const model = Promise.withResolvers<Response>()
const modelRequested = Promise.withResolvers<void>()
const events = createEventStream()
const mutations: string[] = []
const sessionID = "ses_compact"
const location = { directory, project: { id: "project", directory, canonical: directory } }
const calls = createFetch(async (url) => {
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "project",
title: "Compact fixture",
model: { providerID: "demo", id: "model" },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`)
return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
if (url.pathname === `/api/session/${sessionID}/model`) {
mutations.push("model")
modelRequested.resolve()
return model.promise
}
if (url.pathname === `/api/session/${sessionID}/compact`) {
mutations.push("compact")
return json({ data: {} })
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("/compact")
setup.mockInput.pressEnter()
const frame = await setup.waitForFrame((frame) => frame.includes("Compaction queued"))
expect(frame).not.toContain("/compact")
await setup.mockInput.typeText("/compact")
setup.mockInput.pressEnter()
await setup.renderOnce()
expect(setup.captureCharFrame().match(/Compaction queued/g)).toHaveLength(1)
await modelRequested.promise
expect(mutations).toEqual(["model"])
model.resolve(json({ message: "Model setup failed" }, { status: 400 }))
const rejected = await setup.waitForFrame((frame) => frame.includes("Model setup failed"))
expect(rejected).not.toContain("Compaction queued")
expect(mutations).toEqual(["model"])
} finally {
model.resolve(new Response(null, { status: 204 }))
setup.renderer.destroy()
await task
await server.stop()
}
},
)
test.each(["first", "second"])(
"a following prompt commits its selected model after prompt and compaction setup (cached: %s)",
async (initial) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const first = Promise.withResolvers<void>()
const firstRequested = Promise.withResolvers<void>()
const secondRequested = Promise.withResolvers<string>()
const events = createEventStream()
const sessionID = "ses_model_order"
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: sessionID,
projectID: "project",
title: "Model ordering fixture",
agent: "build",
model: { providerID: "demo", id: initial },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const mutations: string[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname === `/api/session/${sessionID}`) return json({ data: session })
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`)
return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({
location,
data: ["first", "second"].map((id) => ({
id,
providerID: "demo",
name: `${id} model`,
variants: [],
cost: [],
time: { released: 0 },
})),
})
if (url.pathname === `/api/session/${sessionID}/model`) {
session.model = (await request.json()).model
mutations.push(`model:${session.model.id}`)
// Delay SSE so local selection must not rely on the cached server model.
return new Response(null, { status: 204 })
}
if (url.pathname === `/api/session/${sessionID}/compact`) {
mutations.push(`compact:${session.model.id}`)
return json({
data: {
id: (await request.json()).id,
sessionID,
type: "compaction",
timeCreated: 10,
payload: {},
delivery: "steer",
},
})
}
if (url.pathname === `/api/session/${sessionID}/prompt`) {
const body = await request.json()
mutations.push(`prompt:${body.text}:${session.model.id}`)
if (body.text === "First prompt") {
firstRequested.resolve()
await first.promise
}
if (body.text === "Second prompt") secondRequested.resolve(session.model.id)
return json({
data: {
id: body.id,
sessionID,
type: "user",
timeCreated: 10,
payload: { text: body.text },
delivery: "steer",
},
})
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
const selectModel = async (id: string) => {
await setup.mockInput.typeText("/models")
setup.mockInput.pressEnter()
await setup.waitForFrame(
(frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable,
)
await setup.mockInput.typeText(id)
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes(`${id} model`) && !frame.includes("Select model"))
}
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes(`${initial} model`))
await setup.mockInput.typeText("First prompt")
setup.mockInput.pressEnter()
await firstRequested.promise
const submitted = mutations.length
if (initial !== "first") await selectModel("first")
await setup.mockInput.typeText("/compact")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Compaction queued"))
await selectModel("second")
await setup.mockInput.typeText("Second prompt")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.split("\n").slice(0, 20).join("\n").includes("Second prompt"))
expect(mutations).toHaveLength(submitted)
first.resolve()
expect(await secondRequested.promise).toBe("second")
expect(mutations.slice(-4)).toEqual([
"model:first",
"compact:first",
"model:second",
"prompt:Second prompt:second",
])
} finally {
first.resolve()
setup.renderer.destroy()
await task
await server.stop()
}
},
)
+2
View File
@@ -135,6 +135,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (request.method === "POST" && /^\/api\/session\/[^/]+\/model$/.test(url.pathname))
return new Response(null, { status: 204 })
if (url.pathname === "/api/permission/request")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
+283 -3
View File
@@ -790,6 +790,16 @@
"type": "null"
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
@@ -977,6 +987,16 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -988,7 +1008,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location.",
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -9872,6 +9892,97 @@
"x-websocket": true
}
},
"/api/experimental/session/{sessionID}/terminal/read": {
"get": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.read",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "lines",
"in": "query",
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.ReadLinesEncoded"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.ReadResult"
},
{
"type": "null"
}
]
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.",
"summary": "Read the session's most recently controlled terminal"
}
},
"/api/experimental/session/{sessionID}/terminal": {
"get": {
"tags": ["persistentPty"],
@@ -10074,6 +10185,70 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -14983,6 +15158,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15493,6 +15671,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15870,7 +16051,34 @@
"additionalProperties": false
}
},
"required": ["command", "args", "cwd", "title", "env"],
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -15967,6 +16175,69 @@
],
"additionalProperties": false
},
"PersistentPty.ReadLinesEncoded": {
"type": "string"
},
"PersistentPty.ReadResult": {
"type": "object",
"properties": {
"ptyID": {
"type": "string",
"pattern": "^pty"
},
"title": {
"type": "string"
},
"cwd": {
"type": "string"
},
"foregroundProcess": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"screen": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"cols": {
"type": "integer",
"exclusiveMinimum": 0
},
"rows": {
"type": "integer",
"exclusiveMinimum": 0
},
"cursor": {
"type": "object",
"properties": {
"x": {
"type": "integer",
"minimum": 0
},
"y": {
"type": "integer",
"minimum": 0
}
},
"required": ["x", "y"],
"additionalProperties": false
}
},
"required": ["text", "cols", "rows", "cursor"],
"additionalProperties": false
}
},
"required": ["ptyID", "title", "cwd", "foregroundProcess", "screen"],
"additionalProperties": false
},
"PersistentPty.Snapshot": {
"type": "object",
"properties": {
@@ -16222,7 +16493,7 @@
},
"Project.Vcs": {
"type": "string",
"enum": ["git", "hg"]
"pattern": "^[a-z][a-z0-9._-]*$"
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -16983,6 +17254,9 @@
"subpath": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/Session.Metadata"
},
"revert": {
"$ref": "#/components/schemas/Session.Revert"
}
@@ -17040,6 +17314,9 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
@@ -17831,6 +18108,9 @@
"required": ["id", "time", "text", "type"],
"additionalProperties": false
},
"Session.Metadata": {
"type": "object"
},
"Session.Revert": {
"type": "object",
"properties": {
+283 -3
View File
@@ -790,6 +790,16 @@
"type": "null"
}
]
},
"metadata": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Metadata"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
@@ -977,6 +987,16 @@
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
@@ -988,7 +1008,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location.",
"description": "Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
"summary": "Import session",
"requestBody": {
"content": {
@@ -9872,6 +9892,97 @@
"x-websocket": true
}
},
"/api/experimental/session/{sessionID}/terminal/read": {
"get": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.read",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "lines",
"in": "query",
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.ReadLinesEncoded"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.ReadResult"
},
{
"type": "null"
}
]
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.",
"summary": "Read the session's most recently controlled terminal"
}
},
"/api/experimental/session/{sessionID}/terminal": {
"get": {
"tags": ["persistentPty"],
@@ -10074,6 +10185,70 @@
}
}
},
"/api/experimental/persistent-pty/handoff": {
"post": {
"tags": ["persistentPty"],
"operationId": "server.experimental.persistentPty.handoff",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"handoff": {
"anyOf": [
{
"$ref": "#/components/schemas/PersistentPty.Handoff"
},
{
"type": "null"
}
]
}
},
"required": ["handoff"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
}
}
},
"/api/experimental/persistent-pty/{ptyID}": {
"get": {
"tags": ["persistentPty"],
@@ -14983,6 +15158,9 @@
"name": {
"type": "string"
},
"metadata": {
"type": "object"
},
"methods": {
"type": "array",
"items": {
@@ -15493,6 +15671,9 @@
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
@@ -15870,7 +16051,34 @@
"additionalProperties": false
}
},
"required": ["command", "args", "cwd", "title", "env"],
"required": ["args", "title", "env"],
"additionalProperties": false
},
"PersistentPty.Handoff": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"instanceID": {
"type": "string"
},
"ticket": {
"type": "string"
},
"expiresAt": {
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"enum": ["Infinity", "-Infinity", "NaN"]
}
]
}
},
"required": ["directory", "instanceID", "ticket", "expiresAt"],
"additionalProperties": false
},
"PersistentPty.Info": {
@@ -15967,6 +16175,69 @@
],
"additionalProperties": false
},
"PersistentPty.ReadLinesEncoded": {
"type": "string"
},
"PersistentPty.ReadResult": {
"type": "object",
"properties": {
"ptyID": {
"type": "string",
"pattern": "^pty"
},
"title": {
"type": "string"
},
"cwd": {
"type": "string"
},
"foregroundProcess": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"screen": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"cols": {
"type": "integer",
"exclusiveMinimum": 0
},
"rows": {
"type": "integer",
"exclusiveMinimum": 0
},
"cursor": {
"type": "object",
"properties": {
"x": {
"type": "integer",
"minimum": 0
},
"y": {
"type": "integer",
"minimum": 0
}
},
"required": ["x", "y"],
"additionalProperties": false
}
},
"required": ["text", "cols", "rows", "cursor"],
"additionalProperties": false
}
},
"required": ["ptyID", "title", "cwd", "foregroundProcess", "screen"],
"additionalProperties": false
},
"PersistentPty.Snapshot": {
"type": "object",
"properties": {
@@ -16222,7 +16493,7 @@
},
"Project.Vcs": {
"type": "string",
"enum": ["git", "hg"]
"pattern": "^[a-z][a-z0-9._-]*$"
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
@@ -16983,6 +17254,9 @@
"subpath": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/Session.Metadata"
},
"revert": {
"$ref": "#/components/schemas/Session.Revert"
}
@@ -17040,6 +17314,9 @@
"created": {
"type": "number"
},
"streamed": {
"type": "number"
},
"completed": {
"type": "number"
}
@@ -17831,6 +18108,9 @@
"required": ["id", "time", "text", "type"],
"additionalProperties": false
},
"Session.Metadata": {
"type": "object"
},
"Session.Revert": {
"type": "object",
"properties": {